From d39b99f0ab0a9c768ace9ef8baca4583c39a8c1e Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 04:32:07 +0900 Subject: [PATCH 01/14] #297 --- .../theatre_comments_controller.rb | 32 ++ backend/app/models/theatre_comment.rb | 4 +- backend/config/routes.rb | 2 + frontend/src/components/TopNav.tsx | 10 +- .../src/pages/theatres/TheatreDetailPage.tsx | 283 +++++++++++++++--- frontend/src/types.ts | 11 +- 6 files changed, 294 insertions(+), 48 deletions(-) create mode 100644 backend/app/controllers/theatre_comments_controller.rb diff --git a/backend/app/controllers/theatre_comments_controller.rb b/backend/app/controllers/theatre_comments_controller.rb new file mode 100644 index 0000000..0f31297 --- /dev/null +++ b/backend/app/controllers/theatre_comments_controller.rb @@ -0,0 +1,32 @@ +class TheatreCommentsController < ApplicationController + def index + no_gt = params[:no_gt].to_i + no_gt = 0 if no_gt.negative? + + comments = TheatreComment + .where(theatre_id: params[:theatre_id]) + .where('no > ?', no_gt) + .order(:no) + + render json: comments.as_json(include: { user: { only: [:id, :name] } }) + end + + def create + return head :unauthorized unless current_user + + content = params[:content] + return head :unprocessable_entity if content.blank? + + theatre = Theatre.find_by(id: params[:theatre_id]) + return head :not_found unless theatre + + comment = nil + theatre.with_lock do + no = theatre.next_comment_no + comment = TheatreComment.create!(theatre:, no:, user: current_user, content:) + theatre.update!(next_comment_no: no + 1) + end + + render json: comment, status: :created + end +end diff --git a/backend/app/models/theatre_comment.rb b/backend/app/models/theatre_comment.rb index 73883e0..e2989be 100644 --- a/backend/app/models/theatre_comment.rb +++ b/backend/app/models/theatre_comment.rb @@ -1,8 +1,8 @@ class TheatreComment < ApplicationRecord - include MyDiscard + include Discard::Model self.primary_key = :theatre_id, :no belongs_to :theatre + belongs_to :user end - diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 57eb470..b9db110 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -78,5 +78,7 @@ Rails.application.routes.draw do put :watching patch :next_post end + + resources :comments, controller: :theatre_comments, only: [:index, :create] end end diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 68e1a6e..9cac0c5 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -79,9 +79,11 @@ export default (({ user }: Props) => { { name: '上位タグ', to: '/tags/implications', visible: false }, { name: 'ニコニコ連携', to: '/tags/nico' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, - // TODO: 本実装時に消す. - // { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ - // { name: '一覧', to: '/theatres' }] }, + { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ + { name: <>第 1 会場, to: '/theatres/1' }, + { name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' }, + { name: <>ニジカ放送局第 1 チャンネル, + to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' }] }, { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ { name: '検索', to: '/wiki' }, { name: '新規', to: '/wiki/new' }, @@ -92,7 +94,7 @@ export default (({ user }: Props) => { visible: wikiPageFlg }, { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] }, - { name: 'ユーザ', to: '/users', subMenu: [ + { name: 'ユーザ', to: '/users/settings', subMenu: [ { name: '一覧', to: '/users', visible: false }, { name: 'お前', to: `/users/${ user?.id }`, visible: false }, { name: '設定', to: '/users/settings', visible: Boolean (user) }] }] diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 61012c6..d1ecc70 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -3,84 +3,221 @@ import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' import PostEmbed from '@/components/PostEmbed' +import PrefetchLink from '@/components/PrefetchLink' +import TagDetailSidebar from '@/components/TagDetailSidebar' import MainArea from '@/components/layout/MainArea' +import SidebarComponent from '@/components/layout/SidebarComponent' import { SITE_TITLE } from '@/config' -import { apiGet, apiPatch, apiPut } from '@/lib/api' +import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/api' import { fetchPost } from '@/lib/posts' +import { dateString } from '@/lib/utils' import type { FC } from 'react' -import type { NiconicoMetadata, NiconicoViewerHandle, Post, Theatre } from '@/types' +import type { NiconicoMetadata, + NiconicoViewerHandle, + Post, + Theatre, + TheatreComment } from '@/types' type TheatreInfo = { hostFlg: boolean postId: number | null postStartedAt: string | null } +const INITIAL_THEATRE_INFO = { hostFlg: false, postId: null, postStartedAt: null } as const + export default (() => { const { id } = useParams () + const commentsRef = useRef (null) const embedRef = useRef (null) + const theatreInfoRef = useRef (INITIAL_THEATRE_INFO) + const videoLengthRef = useRef (0) + const lastCommentNoRef = useRef (0) + const [comments, setComments] = useState ([]) + const [content, setContent] = useState ('') const [loading, setLoading] = useState (false) + const [sending, setSending] = useState (false) const [theatre, setTheatre] = useState (null) - const [theatreInfo, setTheatreInfo] = - useState ({ hostFlg: false, postId: null, postStartedAt: null }) + const [theatreInfo, setTheatreInfo] = useState (INITIAL_THEATRE_INFO) const [post, setPost] = useState (null) - const [videoLength, setVideoLength] = useState (9_999_999_999) + const [videoLength, setVideoLength] = useState (0) + + useEffect (() => { + theatreInfoRef.current = theatreInfo + }, [theatreInfo]) + + useEffect (() => { + videoLengthRef.current = videoLength + }, [videoLength]) + + useEffect (() => { + lastCommentNoRef.current = comments.at (-1)?.no ?? 0 + }, [comments]) useEffect (() => { if (!(id)) return + let cancelled = false + + setComments ([]) + setTheatre (null) + setPost (null) + setTheatreInfo (INITIAL_THEATRE_INFO) + setVideoLength (0) + lastCommentNoRef.current = 0 + void (async () => { - setTheatre (await apiGet (`/theatres/${ id }`)) + try + { + const data = await apiGet (`/theatres/${ id }`) + if (!(cancelled)) + setTheatre (data) + } + catch (error) + { + console.error (error) + } }) () - const interval = setInterval (async () => { - if (theatreInfo.hostFlg - && theatreInfo.postStartedAt - && ((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime () - > videoLength)) - setTheatreInfo ({ hostFlg: true, postId: null, postStartedAt: null }) - else - setTheatreInfo (await apiPut (`/theatres/${ id }/watching`)) - }, 1_000) + return () => { + cancelled = true + } + }, [id]) - return () => clearInterval (interval) - }, [id, theatreInfo.hostFlg, theatreInfo.postStartedAt, videoLength]) + useEffect (() => { + commentsRef.current?.scrollTo ({ + top: commentsRef.current.scrollHeight, + behavior: 'smooth' }) + }, [commentsRef]) useEffect (() => { - if (!(theatreInfo.hostFlg) || loading) + if (!(id)) return - if (theatreInfo.postId == null) + let cancelled = false + let running = false + + const tick = async () => { + if (running) + return + + running = true + + try + { + const newComments = await apiGet ( + `/theatres/${ id }/comments`, + { params: { no_gt: lastCommentNoRef.current } }) + + if (!(cancelled) && newComments.length > 0) + { + lastCommentNoRef.current = newComments[newComments.length - 1].no + setComments (prev => [...prev, ...newComments]) + } + + const currentInfo = theatreInfoRef.current + const ended = + currentInfo.hostFlg + && currentInfo.postStartedAt + && ((Date.now () - (new Date (currentInfo.postStartedAt)).getTime ()) + > videoLengthRef.current + 3_000) + + if (ended) + { + if (!(cancelled)) + setTheatreInfo (prev => ({ ...prev, postId: null, postStartedAt: null })) + + return + } + + const nextInfo = await apiPut (`/theatres/${ id }/watching`) + if (!(cancelled)) + setTheatreInfo (nextInfo) + } + catch (error) + { + console.error (error) + } + finally { - void (async () => { - setLoading (true) - await apiPatch (`/theatres/${ id }/next_post`) + running = false + } + } + + tick () + const interval = setInterval (() => tick (), 1_000) + + return () => { + cancelled = true + clearInterval (interval) + } + }, [id]) + + useEffect (() => { + if (!(id) || !(theatreInfo.hostFlg) || loading || theatreInfo.postId != null) + return + + let cancelled = false + + void (async () => { + setLoading (true) + + try + { + await apiPatch (`/theatres/${ id }/next_post`) + } + catch (error) + { + console.error (error) + } + finally + { + if (!(cancelled)) setLoading (false) - }) () - return } + }) () + + return () => { + cancelled = true + } }, [id, loading, theatreInfo.hostFlg, theatreInfo.postId]) useEffect (() => { if (theatreInfo.postId == null) return + let cancelled = false + void (async () => { - setPost (await fetchPost (String (theatreInfo.postId))) + try + { + const nextPost = await fetchPost (String (theatreInfo.postId)) + if (!(cancelled)) + setPost (nextPost) + } + catch (error) + { + console.error (error) + } }) () + + return () => { + cancelled = true + } }, [theatreInfo.postId, theatreInfo.postStartedAt]) const syncPlayback = (meta: NiconicoMetadata) => { if (!(theatreInfo.postStartedAt)) return - const targetTime = - ((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime ()) + const targetTime = Math.min ( + Math.max (0, Date.now () - (new Date (theatreInfo.postStartedAt)).getTime ()), + videoLength) const drift = Math.abs (meta.currentTime - targetTime) @@ -89,7 +226,7 @@ export default (() => { } return ( - +
{theatre && ( @@ -99,16 +236,82 @@ export default (() => { )} - {post && ( - { - embedRef.current?.play () - setVideoLength (info.lengthInSeconds * 1_000) - }} - onMetadataChange={meta => { - syncPlayback (meta) - }}/>)} - ) +
+ +
+ + + {post ? ( + <> + { + embedRef.current?.play () + setVideoLength (info.lengthInSeconds * 1_000) + }} + onMetadataChange={syncPlayback}/> +
+ <>再生中: + + {post.title || post.url} + +
+ ) : 'Loading...'} +
+ + + {comments.length > 0 && ( +
{ + e.preventDefault () + + if (!(content)) + return + + try + { + setSending (true) + await apiPost (`/theatres/${ id }/comments`, { content }) + setContent ('') + commentsRef.current?.scrollTo ({ + top: commentsRef.current.scrollHeight, + behavior: 'smooth' }) + } + finally + { + setSending (false) + } + }}> +
+ {comments.map (comment => ( +
+
+ {comment.content} +
+
+ by {comment.user ? (comment.user.name || '名もなきニジラー') : '運営'} +
+
+ {dateString (comment.createdAt)} +
+
))} +
+ setContent (e.target.value)} + disabled={sending}/> +
)} +
+ +
+ +
+
) }) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6538121..d95eb27 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -29,7 +29,7 @@ export type FetchPostsParams = { export type Menu = MenuItem[] export type MenuItem = { - name: string + name: ReactNode to: string base?: string subMenu: SubMenuItem[] } @@ -93,7 +93,7 @@ export type PostTagChange = { export type SubMenuItem = | { component: ReactNode visible: boolean } - | { name: string + | { name: ReactNode to: string visible?: boolean } @@ -115,6 +115,13 @@ export type Theatre = { createdAt: string updatedAt: string } +export type TheatreComment = { + theatreId: number, + no: number, + user: { id: number, name: string } | null + content: string + createdAt: string } + export type User = { id: number name: string | null -- 2.34.1 From 1a451c5038c54b0613c2b7821822cdec94f7059f Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 04:39:09 +0900 Subject: [PATCH 02/14] #297 --- .../src/pages/theatres/TheatreDetailPage.tsx | 93 +++++++++---------- 1 file changed, 46 insertions(+), 47 deletions(-) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index d1ecc70..79edd41 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -261,53 +261,52 @@ export default (() => {
- {comments.length > 0 && ( -
{ - e.preventDefault () - - if (!(content)) - return - - try - { - setSending (true) - await apiPost (`/theatres/${ id }/comments`, { content }) - setContent ('') - commentsRef.current?.scrollTo ({ - top: commentsRef.current.scrollHeight, - behavior: 'smooth' }) - } - finally - { - setSending (false) - } - }}> -
- {comments.map (comment => ( -
-
- {comment.content} -
-
- by {comment.user ? (comment.user.name || '名もなきニジラー') : '運営'} -
-
- {dateString (comment.createdAt)} -
-
))} -
- setContent (e.target.value)} - disabled={sending}/> -
)} +
{ + e.preventDefault () + + if (!(content)) + return + + try + { + setSending (true) + await apiPost (`/theatres/${ id }/comments`, { content }) + setContent ('') + commentsRef.current?.scrollTo ({ + top: commentsRef.current.scrollHeight, + behavior: 'smooth' }) + } + finally + { + setSending (false) + } + }}> +
+ {comments.map (comment => ( +
+
+ {comment.content} +
+
+ by {comment.user ? (comment.user.name || '名もなきニジラー') : '運営'} +
+
+ {dateString (comment.createdAt)} +
+
))} +
+ setContent (e.target.value)} + disabled={sending}/> +
-- 2.34.1 From 3469c58b53d51b6d75c6ef0c3c9531ea76bf8532 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 04:57:46 +0900 Subject: [PATCH 03/14] #297 --- backend/spec/factories/theatre_comments.rb | 8 + backend/spec/factories/theatres.rb | 11 ++ .../spec/requests/theatre_comments_spec.rb | 150 ++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 backend/spec/factories/theatre_comments.rb create mode 100644 backend/spec/factories/theatres.rb create mode 100644 backend/spec/requests/theatre_comments_spec.rb diff --git a/backend/spec/factories/theatre_comments.rb b/backend/spec/factories/theatre_comments.rb new file mode 100644 index 0000000..a45cec4 --- /dev/null +++ b/backend/spec/factories/theatre_comments.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :theatre_comment do + association :theatre + association :user + sequence (:no) { |n| n } + content { 'test comment' } + end +end diff --git a/backend/spec/factories/theatres.rb b/backend/spec/factories/theatres.rb new file mode 100644 index 0000000..47366ea --- /dev/null +++ b/backend/spec/factories/theatres.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :theatre do + name { 'Test Theatre' } + kind { 1 } + opens_at { Time.current } + closes_at { 1.day.from_now } + next_comment_no { 1 } + + association :created_by_user, factory: :user + end +end diff --git a/backend/spec/requests/theatre_comments_spec.rb b/backend/spec/requests/theatre_comments_spec.rb new file mode 100644 index 0000000..a9f04a0 --- /dev/null +++ b/backend/spec/requests/theatre_comments_spec.rb @@ -0,0 +1,150 @@ +require 'rails_helper' + +RSpec.describe 'TheatreComments', type: :request do + def sign_in_as(user) + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user) + end + + describe 'GET /theatres/:theatre_id/comments' do + let(:theatre) { create(:theatre) } + let(:other_theatre) { create(:theatre) } + let(:alice) { create(:user, name: 'Alice') } + let(:bob) { create(:user, name: 'Bob') } + + let!(:comment_3) do + create( + :theatre_comment, + theatre: theatre, + no: 3, + user: alice, + content: 'third comment' + ) + end + + let!(:comment_1) do + create( + :theatre_comment, + theatre: theatre, + no: 1, + user: alice, + content: 'first comment' + ) + end + + let!(:comment_2) do + create( + :theatre_comment, + theatre: theatre, + no: 2, + user: bob, + content: 'second comment' + ) + end + + let!(:other_comment) do + create( + :theatre_comment, + theatre: other_theatre, + no: 1, + user: bob, + content: 'other theatre comment' + ) + end + + it 'theatre_id で絞り込み、no_gt より大きいものを no 昇順で返す' do + get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body.map { |row| row['no'] }).to eq([2, 3]) + expect(response.parsed_body.map { |row| row['content'] }).to eq([ + 'second comment', + 'third comment' + ]) + end + + it 'user は id と name だけを含む' do + get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 } + + expect(response).to have_http_status(:ok) + + expect(response.parsed_body.first['user']).to eq({ + 'id' => bob.id, + 'name' => 'Bob' + }) + expect(response.parsed_body.first['user'].keys).to contain_exactly('id', 'name') + end + + it 'no_gt が負数なら 0 として扱う' do + get "/theatres/#{theatre.id}/comments", params: { no_gt: -100 } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body.map { |row| row['no'] }).to eq([1, 2, 3]) + end + end + + describe 'POST /theatres/:theatre_id/comments' do + let(:user) { create(:user, name: 'Alice') } + let(:theatre) { create(:theatre, next_comment_no: 2) } + + before do + create( + :theatre_comment, + theatre: theatre, + no: 1, + user: user, + content: 'existing comment' + ) + end + + it '未ログインなら 401 を返す' do + expect { + post "/theatres/#{theatre.id}/comments", params: { content: 'hello' } + }.not_to change(TheatreComment, :count) + + expect(response).to have_http_status(:unauthorized) + end + + it 'content が blank なら 422 を返す' do + sign_in_as(user) + + expect { + post "/theatres/#{theatre.id}/comments", params: { content: ' ' } + }.not_to change(TheatreComment, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'theatre が存在しなければ 404 を返す' do + sign_in_as(user) + + expect { + post '/theatres/999999/comments', params: { content: 'hello' } + }.not_to change(TheatreComment, :count) + + expect(response).to have_http_status(:not_found) + end + + it 'コメントを作成し、user を紐づけ、next_comment_no を進める' do + sign_in_as(user) + + expect { + post "/theatres/#{theatre.id}/comments", params: { content: 'new comment' } + }.to change(TheatreComment, :count).by(1) + + expect(response).to have_http_status(:created) + + comment = TheatreComment.find_by!(theatre: theatre, no: 2) + + expect(comment.user).to eq(user) + expect(comment.content).to eq('new comment') + expect(theatre.reload.next_comment_no).to eq(3) + + expect(response.parsed_body.slice('theatre_id', 'no', 'user_id', 'content')).to eq({ + 'theatre_id' => theatre.id, + 'no' => 2, + 'user_id' => user.id, + 'content' => 'new comment' + }) + end + end +end -- 2.34.1 From 83c801d8cea4352513305352a8da5610ab064adf Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 05:16:04 +0900 Subject: [PATCH 04/14] #297 --- frontend/src/pages/theatres/TheatreDetailPage.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 79edd41..dac7856 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -150,7 +150,7 @@ export default (() => { } tick () - const interval = setInterval (() => tick (), 1_000) + const interval = setInterval (() => tick (), 5_000) return () => { cancelled = true @@ -188,6 +188,9 @@ export default (() => { }, [id, loading, theatreInfo.hostFlg, theatreInfo.postId]) useEffect (() => { + setPost (null) + setVideoLength (0) + if (theatreInfo.postId == null) return @@ -209,7 +212,7 @@ export default (() => { return () => { cancelled = true } - }, [theatreInfo.postId, theatreInfo.postStartedAt]) + }, [theatreInfo.postId]) const syncPlayback = (meta: NiconicoMetadata) => { if (!(theatreInfo.postStartedAt)) @@ -244,6 +247,7 @@ export default (() => { {post ? ( <> { -- 2.34.1 From 40c1eec2cf814832fa79f7de65d68bebd5efab88 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 05:19:51 +0900 Subject: [PATCH 05/14] #297 --- frontend/src/pages/theatres/TheatreDetailPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index dac7856..a12ac00 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -188,7 +188,6 @@ export default (() => { }, [id, loading, theatreInfo.hostFlg, theatreInfo.postId]) useEffect (() => { - setPost (null) setVideoLength (0) if (theatreInfo.postId == null) -- 2.34.1 From 23d9cf8ad0a710090c9baffeb37f68cf61e76c19 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 05:34:59 +0900 Subject: [PATCH 06/14] #297 --- frontend/src/pages/theatres/TheatreDetailPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index a12ac00..eab10e9 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -185,7 +185,7 @@ export default (() => { return () => { cancelled = true } - }, [id, loading, theatreInfo.hostFlg, theatreInfo.postId]) + }, [id, theatreInfo.hostFlg, theatreInfo.postId]) useEffect (() => { setVideoLength (0) @@ -246,7 +246,7 @@ export default (() => { {post ? ( <> { -- 2.34.1 From e5da633dfeee32107ff04e7b98091c5fd8f8b9c2 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 05:39:44 +0900 Subject: [PATCH 07/14] #297 --- frontend/src/pages/theatres/TheatreDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index eab10e9..6e9541c 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -150,7 +150,7 @@ export default (() => { } tick () - const interval = setInterval (() => tick (), 5_000) + const interval = setInterval (() => tick (), 1_500) return () => { cancelled = true -- 2.34.1 From 40a62ccb22176e5dd048149c519019801dd8ac52 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 12:38:26 +0900 Subject: [PATCH 08/14] #297 --- frontend/src/pages/theatres/TheatreDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 6e9541c..770d6ca 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -289,7 +289,7 @@ export default (() => {
+ border border-black dark:border-white w-full h-[90vh]"> {comments.map (comment => (
-- 2.34.1 From 54a55875b32e9ab141eadc2d1af790ce3c165002 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 12:40:24 +0900 Subject: [PATCH 09/14] #297 --- frontend/src/pages/theatres/TheatreDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 770d6ca..19aeb83 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -289,7 +289,7 @@ export default (() => {
+ border border-black dark:border-white w-full h-[80vh]"> {comments.map (comment => (
-- 2.34.1 From e133c684c8139551547cd6c95b54002faa299399 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 20:06:25 +0900 Subject: [PATCH 10/14] #297 --- frontend/src/pages/theatres/TheatreDetailPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 19aeb83..8e6032b 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -239,7 +239,7 @@ export default (() => {
- + {post && }
@@ -313,7 +313,7 @@ export default (() => {
- + {post && }
) }) satisfies FC -- 2.34.1 From 505961898eaa11c03baf09da4243c2f2b4252886 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 20:52:59 +0900 Subject: [PATCH 11/14] #297 --- frontend/src/pages/theatres/TheatreDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 8e6032b..b9d37aa 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -313,7 +313,7 @@ export default (() => {
- {post && } + {post && }
) }) satisfies FC -- 2.34.1 From 57e82b6ffdfe22def61a260a9e3d47d4e913b09f Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 22 Mar 2026 00:58:58 +0900 Subject: [PATCH 12/14] #297 --- frontend/src/components/PrefetchLink.tsx | 3 +- frontend/src/components/TopNav.tsx | 4 ++ .../src/pages/theatres/TheatreDetailPage.tsx | 38 +++++++++++++++---- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index 0aebe18..18fc855 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -71,7 +71,8 @@ export default forwardRef (({ || ev.metaKey || ev.ctrlKey || ev.shiftKey - || ev.altKey) + || ev.altKey + || (rest.target && rest.target !== '_self')) return ev.preventDefault () diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 354105c..7d1575c 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -211,6 +211,7 @@ export default (({ user }: Props) => { {item.name} )))} @@ -277,6 +278,9 @@ export default (({ user }: Props) => { {subItem.name} )))} diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index b9d37aa..3413a7b 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -23,9 +23,14 @@ import type { NiconicoMetadata, type TheatreInfo = { hostFlg: boolean postId: number | null - postStartedAt: string | null } + postStartedAt: string | null + watchingUsers: { id: number; name: string }[] } -const INITIAL_THEATRE_INFO = { hostFlg: false, postId: null, postStartedAt: null } as const +const INITIAL_THEATRE_INFO = + { hostFlg: false, + postId: null, + postStartedAt: null, + watchingUsers: [] as { id: number; name: string }[] } as const export default (() => { @@ -93,7 +98,7 @@ export default (() => { commentsRef.current?.scrollTo ({ top: commentsRef.current.scrollHeight, behavior: 'smooth' }) - }, [commentsRef]) + }, [comments]) useEffect (() => { if (!(id)) @@ -265,7 +270,7 @@ export default (() => {
{ e.preventDefault () @@ -286,25 +291,42 @@ export default (() => { setSending (false) } }}> +
+ 現在の同接数:{theatreInfo.watchingUsers.length} +
+ +
+
    + {theatreInfo.watchingUsers.map (user => ( +
  • + {user.name || `名もなきニジラー(#${ user.id })`} +
  • ))} +
+
+
+ className="overflow-x-hidden overflow-y-scroll text-wrap w-full + h-[32vh] md:h-[64vh] border rounded"> {comments.map (comment => (
{comment.content}
- by {comment.user ? (comment.user.name || '名もなきニジラー') : '運営'} + by {comment.user + ? (comment.user.name || `名もなきニジラー(#${ comment.user.id })`) + : '運営'}
{dateString (comment.createdAt)}
))}
+ setContent (e.target.value)} -- 2.34.1 From 76f8e6875eaaa85c2a6dac8f13d46d6ab984446c Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 22 Mar 2026 01:00:15 +0900 Subject: [PATCH 13/14] #297 --- backend/app/controllers/theatres_controller.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/app/controllers/theatres_controller.rb b/backend/app/controllers/theatres_controller.rb index 67228d7..7949045 100644 --- a/backend/app/controllers/theatres_controller.rb +++ b/backend/app/controllers/theatres_controller.rb @@ -31,7 +31,9 @@ class TheatresController < ApplicationController post_started_at = theatre.current_post_started_at end - render json: { host_flg:, post_id:, post_started_at: } + render json: { + host_flg:, post_id:, post_started_at:, + watching_users: theatre.watching_users.as_json(only: [:id, :name]) } end def next_post -- 2.34.1 From 63c1dd197c121677b9acf51574b29bb01accf434 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 22 Mar 2026 19:52:14 +0900 Subject: [PATCH 14/14] #297 --- .../theatre_comments_controller.rb | 2 +- .../spec/requests/theatre_comments_spec.rb | 14 ++-- backend/spec/requests/theatres_spec.rb | 22 ++++++- .../src/pages/theatres/TheatreDetailPage.tsx | 66 +++++++++---------- 4 files changed, 61 insertions(+), 43 deletions(-) diff --git a/backend/app/controllers/theatre_comments_controller.rb b/backend/app/controllers/theatre_comments_controller.rb index 0f31297..50ec9ef 100644 --- a/backend/app/controllers/theatre_comments_controller.rb +++ b/backend/app/controllers/theatre_comments_controller.rb @@ -6,7 +6,7 @@ class TheatreCommentsController < ApplicationController comments = TheatreComment .where(theatre_id: params[:theatre_id]) .where('no > ?', no_gt) - .order(:no) + .order(no: :desc) render json: comments.as_json(include: { user: { only: [:id, :name] } }) end diff --git a/backend/spec/requests/theatre_comments_spec.rb b/backend/spec/requests/theatre_comments_spec.rb index a9f04a0..856b309 100644 --- a/backend/spec/requests/theatre_comments_spec.rb +++ b/backend/spec/requests/theatre_comments_spec.rb @@ -51,14 +51,14 @@ RSpec.describe 'TheatreComments', type: :request do ) end - it 'theatre_id で絞り込み、no_gt より大きいものを no 昇順で返す' do + it 'theatre_id で絞り込み、no_gt より大きいものを no 降順で返す' do get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 } expect(response).to have_http_status(:ok) - expect(response.parsed_body.map { |row| row['no'] }).to eq([2, 3]) + expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2]) expect(response.parsed_body.map { |row| row['content'] }).to eq([ - 'second comment', - 'third comment' + 'third comment', + 'second comment' ]) end @@ -68,8 +68,8 @@ RSpec.describe 'TheatreComments', type: :request do expect(response).to have_http_status(:ok) expect(response.parsed_body.first['user']).to eq({ - 'id' => bob.id, - 'name' => 'Bob' + 'id' => alice.id, + 'name' => 'Alice' }) expect(response.parsed_body.first['user'].keys).to contain_exactly('id', 'name') end @@ -78,7 +78,7 @@ RSpec.describe 'TheatreComments', type: :request do get "/theatres/#{theatre.id}/comments", params: { no_gt: -100 } expect(response).to have_http_status(:ok) - expect(response.parsed_body.map { |row| row['no'] }).to eq([1, 2, 3]) + expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1]) end end diff --git a/backend/spec/requests/theatres_spec.rb b/backend/spec/requests/theatres_spec.rb index bee54f3..45a4b85 100644 --- a/backend/spec/requests/theatres_spec.rb +++ b/backend/spec/requests/theatres_spec.rb @@ -117,11 +117,18 @@ RSpec.describe 'Theatres API', type: :request do expect(theatre.host_user_id).to eq(member.id) expect(watch.expires_at).to be_within(1.second).of(30.seconds.from_now) - expect(json).to eq( + expect(json).to include( 'host_flg' => true, 'post_id' => nil, 'post_started_at' => nil ) + + expect(json.fetch('watching_users')).to contain_exactly( + { + 'id' => member.id, + 'name' => 'member user' + } + ) end end @@ -167,11 +174,22 @@ RSpec.describe 'Theatres API', type: :request do expect(response).to have_http_status(:ok) expect(theatre.reload.host_user_id).to eq(other_user.id) - expect(json).to eq( + expect(json).to include( 'host_flg' => false, 'post_id' => nil, 'post_started_at' => nil ) + + expect(json.fetch('watching_users')).to contain_exactly( + { + 'id' => member.id, + 'name' => 'member user' + }, + { + 'id' => other_user.id, + 'name' => 'other user' + } + ) end end diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 3413a7b..ba8ec75 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' +import ErrorScreen from '@/components/ErrorScreen' import PostEmbed from '@/components/PostEmbed' import PrefetchLink from '@/components/PrefetchLink' import TagDetailSidebar from '@/components/TagDetailSidebar' @@ -46,6 +47,7 @@ export default (() => { const [content, setContent] = useState ('') const [loading, setLoading] = useState (false) const [sending, setSending] = useState (false) + const [status, setStatus] = useState (200) const [theatre, setTheatre] = useState (null) const [theatreInfo, setTheatreInfo] = useState (INITIAL_THEATRE_INFO) const [post, setPost] = useState (null) @@ -60,7 +62,7 @@ export default (() => { }, [videoLength]) useEffect (() => { - lastCommentNoRef.current = comments.at (-1)?.no ?? 0 + lastCommentNoRef.current = comments[0]?.no ?? 0 }, [comments]) useEffect (() => { @@ -85,7 +87,7 @@ export default (() => { } catch (error) { - console.error (error) + setStatus ((error as any)?.response.status ?? 200) } }) () @@ -94,12 +96,6 @@ export default (() => { } }, [id]) - useEffect (() => { - commentsRef.current?.scrollTo ({ - top: commentsRef.current.scrollHeight, - behavior: 'smooth' }) - }, [comments]) - useEffect (() => { if (!(id)) return @@ -122,7 +118,7 @@ export default (() => { if (!(cancelled) && newComments.length > 0) { lastCommentNoRef.current = newComments[newComments.length - 1].no - setComments (prev => [...prev, ...newComments]) + setComments (prev => [...newComments, ...prev]) } const currentInfo = theatreInfoRef.current @@ -232,6 +228,9 @@ export default (() => { embedRef.current?.seek (targetTime) } + if (status >= 400) + return + return (
@@ -270,7 +269,7 @@ export default (() => { { e.preventDefault () @@ -282,28 +281,20 @@ export default (() => { setSending (true) await apiPost (`/theatres/${ id }/comments`, { content }) setContent ('') - commentsRef.current?.scrollTo ({ - top: commentsRef.current.scrollHeight, - behavior: 'smooth' }) + commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' }) } finally { setSending (false) } }}> -
- 現在の同接数:{theatreInfo.watchingUsers.length} -
- -
-
    - {theatreInfo.watchingUsers.map (user => ( -
  • - {user.name || `名もなきニジラー(#${ user.id })`} -
  • ))} -
-
+ setContent (e.target.value)} + disabled={sending}/>
{
))}
- - setContent (e.target.value)} - disabled={sending}/> + +
+
+ 現在の同接数:{theatreInfo.watchingUsers.length} +
+ +
+
    + {theatreInfo.watchingUsers.map (user => ( +
  • + {user.name || `名もなきニジラー(#${ user.id })`} +
  • ))} +
+
+
-- 2.34.1