diff --git a/backend/app/controllers/theatre_comments_controller.rb b/backend/app/controllers/theatre_comments_controller.rb new file mode 100644 index 0000000..50ec9ef --- /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: :desc) + + 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/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 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/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..856b309 --- /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([3, 2]) + expect(response.parsed_body.map { |row| row['content'] }).to eq([ + 'third comment', + 'second 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' => alice.id, + 'name' => 'Alice' + }) + 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([3, 2, 1]) + 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 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/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 ec4483a..7d1575c 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) }] }] @@ -209,6 +211,7 @@ export default (({ user }: Props) => { {item.name} )))} @@ -275,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 61012c6..ba8ec75 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -2,85 +2,225 @@ 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' 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 } + postStartedAt: string | null + watchingUsers: { id: number; name: string }[] } + +const INITIAL_THEATRE_INFO = + { hostFlg: false, + postId: null, + postStartedAt: null, + watchingUsers: [] as { id: number; name: string }[] } 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 [status, setStatus] = useState (200) 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[0]?.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) + { + setStatus ((error as any)?.response.status ?? 200) + } }) () - 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 (() => { + if (!(id)) + return + + 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 => [...newComments, ...prev]) + } + + 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 + { + running = false + } + } + + tick () + const interval = setInterval (() => tick (), 1_500) + + return () => { + cancelled = true + clearInterval (interval) + } + }, [id]) useEffect (() => { - if (!(theatreInfo.hostFlg) || loading) + if (!(id) || !(theatreInfo.hostFlg) || loading || theatreInfo.postId != null) return - if (theatreInfo.postId == null) + let cancelled = false + + void (async () => { + setLoading (true) + + try { - void (async () => { - setLoading (true) - await apiPatch (`/theatres/${ id }/next_post`) + await apiPatch (`/theatres/${ id }/next_post`) + } + catch (error) + { + console.error (error) + } + finally + { + if (!(cancelled)) setLoading (false) - }) () - return } - }, [id, loading, theatreInfo.hostFlg, theatreInfo.postId]) + }) () + + return () => { + cancelled = true + } + }, [id, theatreInfo.hostFlg, theatreInfo.postId]) useEffect (() => { + setVideoLength (0) + 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) + } }) () - }, [theatreInfo.postId, theatreInfo.postStartedAt]) + + return () => { + cancelled = true + } + }, [theatreInfo.postId]) 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) @@ -88,8 +228,11 @@ export default (() => { embedRef.current?.seek (targetTime) } + if (status >= 400) + return + return ( - +
{theatre && ( @@ -99,16 +242,100 @@ export default (() => { )} - {post && ( - { - embedRef.current?.play () - setVideoLength (info.lengthInSeconds * 1_000) - }} - onMetadataChange={meta => { - syncPlayback (meta) - }}/>)} - ) +
+ {post && } +
+ + + {post ? ( + <> + { + embedRef.current?.play () + setVideoLength (info.lengthInSeconds * 1_000) + }} + onMetadataChange={syncPlayback}/> +
+ <>再生中: + + {post.title || post.url} + +
+ ) : 'Loading...'} +
+ + +
{ + e.preventDefault () + + if (!(content)) + return + + try + { + setSending (true) + await apiPost (`/theatres/${ id }/comments`, { content }) + setContent ('') + commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' }) + } + finally + { + setSending (false) + } + }}> + setContent (e.target.value)} + disabled={sending}/> + +
+ {comments.map (comment => ( +
+
+ {comment.content} +
+
+ by {comment.user + ? (comment.user.name || `名もなきニジラー(#${ comment.user.id })`) + : '運営'} +
+
+ {dateString (comment.createdAt)} +
+
))} +
+
+ +
+
+ 現在の同接数:{theatreInfo.watchingUsers.length} +
+ +
+
    + {theatreInfo.watchingUsers.map (user => ( +
  • + {user.name || `名もなきニジラー(#${ user.id })`} +
  • ))} +
+
+
+
+ +
+ {post && } +
+
) }) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 36f8b32..d788ebd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -52,7 +52,7 @@ export type FetchTagsParams = { export type Menu = MenuItem[] export type MenuItem = { - name: string + name: ReactNode to: string base?: string subMenu: SubMenuItem[] } @@ -117,7 +117,7 @@ export type PostTagChange = { export type SubMenuItem = | { component: ReactNode visible: boolean } - | { name: string + | { name: ReactNode to: string visible?: boolean } @@ -141,6 +141,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