import React, {Component, PureComponent} from 'react'; import copy from 'copy-to-clipboard'; import {ColorPicker} from './color_picker'; import {split_text, NICKNAME_RE, PID_RE, URL_RE, URL_PID_RE} from './text_splitter'; import {format_time, build_highlight_re, Time, TitleLine, HighlightedText, ClickHandler, ColoredSpan, HighlightedMarkdown} from './Common'; import './Flows.css'; import LazyLoad from './react-lazyload/src'; import {AudioWidget} from './AudioWidget'; import {TokenCtx, ReplyForm} from './UserAction'; import {API, THUHOLE_API_ROOT} from './flows_api'; const IMAGE_BASE=THUHOLE_API_ROOT+'/images/'; const AUDIO_BASE=THUHOLE_API_ROOT+'services/thuhole/audios/'; const CLICKABLE_TAGS={a: true, audio: true}; const PREVIEW_REPLY_COUNT=10; // const QUOTE_BLACKLIST=['23333','233333','66666','666666','10086','10000','100000','99999','999999','55555','555555']; const QUOTE_BLACKLIST=[]; const FOLD_TAGS = ['性相关', '政治相关', '折叠'] window.LATEST_POST_ID=parseInt(localStorage['_LATEST_POST_ID'],10)||0; const DZ_NAME='洞主'; function load_single_meta(show_sidebar,token) { return (pid,replace=false)=>{ let color_picker=new ColorPicker(); let title_elem='树洞 #'+pid; show_sidebar( title_elem,
正在加载 #{pid}
, replace?'replace':'push' ); API.get_single(pid,token) .then((single)=>{ single.data.variant={}; return new Promise((resolve,reject)=>{ API.load_replies_with_cache(pid,token,color_picker,parseInt(single.data.reply)) .then((replies)=>{resolve([single,replies])}) .catch(reject); }); }) .then((res)=>{ let [single,replies]=res; show_sidebar( title_elem, , 'replace' ) }) .catch((e)=>{ console.error(e); show_sidebar( title_elem,

load_single_meta(show_sidebar,token)(pid,true)}>重新加载

{''+e}

, 'replace' ); }) }; } class Reply extends PureComponent { constructor(props) { super(props); } render() { const replyContent = (this.props.info.text) const splitIdx = replyContent.indexOf(']') const author = replyContent.substr(0, splitIdx + 1), replyText = replyContent.substr(splitIdx + 2) return (
#{this.props.info.cid} {!!this.props.do_filter_name && {this.props.do_filter_name(this.props.info.name);}}> }   {this.props.info.tag!==null && {this.props.info.tag} }
); } } class FlowItem extends PureComponent { constructor(props) { super(props); } copy_link(event) { event.preventDefault(); copy( `${event.target.href}${this.props.info.tag ? ' 【'+this.props.info.tag+'】' : ''}\n`+ `${this.props.info.text}${this.props.info.type==='image'?' [图片]':this.props.info.type==='audio'?' [语音]':''}\n`+ `(${format_time(new Date(this.props.info.timestamp*1000))} ${this.props.info.likenum}关注 ${this.props.info.reply}回复)\n`+ this.props.replies.map((r)=>( (r.tag ? '【'+r.tag+'】' : '')+ r.text )).join('\n') ); } render() { let props=this.props; return (
{!!props.is_quote &&
提到
}
{!!window.LATEST_POST_ID && parseInt(props.info.pid,10)>window.LATEST_POST_ID &&
}
{!!this.props.do_filter_name && {this.props.do_filter_name(DZ_NAME);}}> } {!!parseInt(props.info.likenum,10) && {props.info.likenum}  } {!!parseInt(props.info.reply,10) && {props.info.reply}  } #{props.info.pid}   {(props.info.tag!==null && props.info.tag!=='折叠') && {props.info.tag} }
-1) ? '_此树洞已被折叠_' : props.info.text} color_picker={props.color_picker} show_pid={props.show_pid} /> {((props.info.type==='image') && ((props.img_clickable) || !(FOLD_TAGS.indexOf(props.info.tag) > -1))) &&

{props.img_clickable ? : }

} {/*{props.info.type==='audio' && }*/}
{!!(props.attention && props.info.variant.latest_reply) &&

最新回复

}
); } } class FlowSidebar extends PureComponent { constructor(props) { super(props); this.state={ attention: props.attention, info: props.info, replies: props.replies, loading_status: 'done', error_msg: null, filter_name: null, rev: false, }; this.color_picker=props.color_picker; this.syncState=props.sync_state||(()=>{}); this.reply_ref=React.createRef(); } set_variant(cid,variant) { this.setState((prev)=>{ if(cid) return { replies: prev.replies.map((reply)=>{ if(reply.cid===cid) return Object.assign({},reply,{variant: Object.assign({},reply.variant,variant)}); else return reply; }), }; else return { info: Object.assign({},prev.info,{variant: Object.assign({},prev.info.variant,variant)}), } },function() { this.syncState({ info: this.state.info, replies: this.state.replies, }); }); } load_replies(update_count=true) { this.setState({ loading_status: 'loading', error_msg: null, }); API.load_replies(this.state.info.pid,this.props.token,this.color_picker,null) .then((json)=>{ this.setState((prev,props)=>({ replies: json.data, info: update_count ? Object.assign({}, prev.info, { reply: ''+json.data.length, }) : prev.info, attention: !!json.attention, loading_status: 'done', error_msg: null, }), ()=>{ this.syncState({ replies: this.state.replies, attention: this.state.attention, info: this.state.info, }); if(this.state.replies.length) this.set_variant(null,{latest_reply: Math.max.apply(null,this.state.replies.map((r)=>parseInt(r.timestamp)))}); }); }) .catch((e)=>{ console.error(e); this.setState({ replies: [], loading_status: 'done', error_msg: ''+e, }); }); } toggle_attention() { this.setState({ loading_status: 'loading', }); const next_attention=!this.state.attention; API.set_attention(this.state.info.pid,next_attention,this.props.token) .then((json)=>{ this.setState({ loading_status: 'done', attention: next_attention, }); this.syncState({ attention: next_attention, }); }) .catch((e)=>{ this.setState({ loading_status: 'done' }); alert('设置关注失败'); console.error(e); }); } report() { let reason=prompt(`举报 #${this.state.info.pid} 的理由:`); if(reason!==null) { API.report(this.state.info.pid,reason,this.props.token) .then((json)=>{ alert('举报成功'); }) .catch((e)=>{ alert('举报失败'); console.error(e); }) } } set_filter_name(name) { this.setState((prevState)=>({ filter_name: name===prevState.filter_name ? null : name, })); } toggle_rev() { this.setState((prevState)=>({ rev: !prevState.rev, })); } show_reply_bar(name,event) { if(this.reply_ref.current && !event.target.closest('a, .clickable')) { let text=this.reply_ref.current.get(); if(/^\s*(?:Re (?:|洞主|(?:[A-Z][a-z]+ )?(?:[A-Z][a-z]+)|You Win(?: \d+)?):)?\s*$/.test(text)) {// text is nearly empty so we can replace it let should_text='Re '+name+': '; if(should_text===this.reply_ref.current.get()) this.reply_ref.current.set(''); else this.reply_ref.current.set(should_text); } } } render() { if(this.state.loading_status==='loading') return (

加载中……

); let show_pid=load_single_meta(this.props.show_sidebar,this.props.token); let replies_to_show=this.state.filter_name ? this.state.replies.filter((r)=>r.name===this.state.filter_name) : this.state.replies.slice(); if(this.state.rev) replies_to_show.reverse(); // key for lazyload elem let view_mode_key=(this.state.rev ? 'y-' : 'n-')+(this.state.filter_name||'null'); let replies_cnt={[DZ_NAME]:1}; replies_to_show.forEach((r)=>{ if(replies_cnt[r.name]===undefined) replies_cnt[r.name]=0; replies_cnt[r.name]++; }); // hide main thread when filtered let main_thread_elem=(this.state.filter_name && this.state.filter_name!==DZ_NAME) ? null : ( {this.show_reply_bar('',e);}}> {this.set_variant(null,variant);}} do_filter_name={replies_cnt[DZ_NAME]>1 ? this.set_filter_name.bind(this) : null} /> ); return (
{!!this.props.token &&    } {(this.state.replies.length>=1 || this.state.rev) &&    } {!!this.props.token &&    { this.toggle_attention(); }}> {this.state.attention ? : } }
{!!this.state.filter_name &&

{this.set_filter_name(null)}}>还原  当前只看  {this.state.filter_name}

} {!this.state.rev && main_thread_elem } {!!this.state.error_msg &&

回复加载失败

{this.state.error_msg}

} {(this.props.deletion_detect && parseInt(this.state.info.reply)>this.state.replies.length) && !!this.state.replies.length &&
{parseInt(this.state.info.reply)-this.state.replies.length} 条回复被删除
} {replies_to_show.map((reply)=>( {this.show_reply_bar(reply.name,e);}}> {this.set_variant(reply.cid,variant);}} do_filter_name={replies_cnt[reply.name]>1 ? this.set_filter_name.bind(this) : null} /> ))} {this.state.rev && main_thread_elem } {!!this.props.token ? :
登录后可以回复树洞
}
) } } class FlowItemRow extends PureComponent { constructor(props) { super(props); this.state={ replies: [], reply_status: 'done', reply_error: null, info: Object.assign({},props.info,{variant: {}}), attention: props.attention_override===null ? false : props.attention_override, }; this.color_picker=new ColorPicker(); } componentDidMount() { if(parseInt(this.state.info.reply,10)) { this.load_replies(null,/*update_count=*/false); } } load_replies(callback,update_count=true) { console.log('fetching reply',this.state.info.pid); this.setState({ reply_status: 'loading', reply_error: null, }); API.load_replies_with_cache(this.state.info.pid,this.props.token,this.color_picker,parseInt(this.state.info.reply)) .then((json)=>{ this.setState((prev,props)=>({ replies: json.data, info: Object.assign({}, prev.info, { reply: update_count ? ''+json.data.length : prev.info.reply, variant: json.data.length ? { latest_reply: Math.max.apply(null,json.data.map((r)=>parseInt(r.timestamp))), } : {}, }), attention: !!json.attention, reply_status: 'done', reply_error: null, }),callback); }) .catch((e)=>{ console.error(e); this.setState({ replies: [], reply_status: 'failed', reply_error: ''+e, },callback); }); } show_sidebar() { this.props.show_sidebar( '树洞 #'+this.state.info.pid, ); } render() { let show_pid=load_single_meta(this.props.show_sidebar,this.props.token,[this.state.info.pid]); let hl_rules=[ ['url_pid',URL_PID_RE], ['url',URL_RE], ['pid',PID_RE], ['nickname',NICKNAME_RE], ]; if(this.props.search_param) hl_rules.push(['search',build_highlight_re(this.props.search_param,' ','gi')]); let parts=split_text(this.state.info.text,hl_rules); let quote_id=null; if(!this.props.is_quote) for(let [mode,content] of parts) { content = content.length > 0 ? content.substring(1) : content if(mode==='pid' && QUOTE_BLACKLIST.indexOf(content)===-1 && parseInt(content){ if(!CLICKABLE_TAGS[event.target.tagName.toLowerCase()]) this.show_sidebar(); }}>
{this.state.reply_status==='loading' &&
加载中
} {this.state.reply_status==='failed' && } {this.state.replies.slice(0,PREVIEW_REPLY_COUNT).map((reply)=>( ))} {this.state.replies.length>PREVIEW_REPLY_COUNT &&
还有 {this.state.replies.length-PREVIEW_REPLY_COUNT} 条
}
); return quote_id ? (
{res}
) : res; } } class FlowItemQuote extends PureComponent { constructor(props) { super(props); this.state={ loading_status: 'empty', error_msg: null, info: null, }; } componentDidMount() { this.load(); } load() { this.setState({ loading_status: 'loading', },()=>{ API.get_single(this.props.pid,this.props.token) .then((json)=>{ this.setState({ loading_status: 'done', info: json.data, }); }) .catch((err)=>{ if((''+err).indexOf('没有这条树洞')!==-1) this.setState({ loading_status: 'empty', }); else this.setState({ loading_status: 'error', error_msg: ''+err, }); }); }); } render() { if(this.state.loading_status==='empty') return null; else if(this.state.loading_status==='loading') return (
提到了 #{this.props.pid}
); else if(this.state.loading_status==='error') return (

重新加载

{this.state.error_msg}

); else // 'done' return ( ); } } function FlowChunk(props) { return ( {({value: token})=>(
{!!props.title && } {props.list.map((info,ind)=>(
{!!(props.deletion_detect && props.mode==='list' && ind && props.list[ind-1].pid-info.pid>1) &&
{props.list[ind-1].pid-info.pid-1} 条被删除
}
))}
)}
); } export class Flow extends PureComponent { constructor(props) { super(props); this.state={ mode: props.mode, search_param: props.search_text, loaded_pages: 0, chunks: { title: '', data: [], }, loading_status: 'done', error_msg: null, }; this.on_scroll_bound=this.on_scroll.bind(this); window.LATEST_POST_ID=parseInt(localStorage['_LATEST_POST_ID'],10)||0; } load_page(page) { const failed=(err)=>{ console.error(err); this.setState((prev,props)=>({ loaded_pages: prev.loaded_pages-1, loading_status: 'failed', error_msg: ''+err, })); }; if(page>this.state.loaded_pages+1) throw new Error('bad page'); if(page===this.state.loaded_pages+1) { console.log('fetching page',page); if(this.state.mode==='list') { API.get_list(page,this.props.token) .then((json)=>{ if(page===1 && json.data.length) { // update latest_post_id let max_id=-1; json.data.forEach((x)=>{ if(parseInt(x.pid,10)>max_id) max_id=parseInt(x.pid,10); }); localStorage['_LATEST_POST_ID']=''+max_id; } this.setState((prev,props)=>({ chunks: { title: 'News Feed', data: prev.chunks.data.concat(json.data.filter((x)=>( prev.chunks.data.length===0 || !(prev.chunks.data.slice(-100).some((p)=>p.pid===x.pid)) ))), }, loading_status: 'done', })); }) .catch(failed); } else if(this.state.mode==='search') { API.get_search(page,this.state.search_param,this.props.token) .then((json)=>{ const finished=json.data.length===0; this.setState((prev,props)=>({ chunks: { title: 'Result for "'+this.state.search_param+'"', data: prev.chunks.data.concat(json.data.filter((x)=>( prev.chunks.data.length===0 || !(prev.chunks.data.slice(-100).some((p)=>p.pid===x.pid)) ))), }, mode: finished ? 'search_finished' : 'search', loading_status: 'done', })); }) .catch(failed); } else if(this.state.mode==='single') { const pid=parseInt(this.state.search_param.substr(1),10); API.get_single(pid,this.props.token) .then((json)=>{ this.setState({ chunks: { title: 'PID = '+pid, data: [json.data], }, mode: 'single_finished', loading_status: 'done', }); }) .catch(failed); } else if(this.state.mode==='attention') { API.get_attention(this.props.token) .then((json)=>{ this.setState({ chunks: { title: 'Attention List', data: json.data, }, mode: 'attention_finished', loading_status: 'done', }); }) .catch(failed); } else { console.log('nothing to load'); return; } this.setState((prev,props)=>({ loaded_pages: prev.loaded_pages+1, loading_status: 'loading', error_msg: null, })); } } on_scroll(event) { if(event.target===document) { const avail=document.body.scrollHeight-window.scrollY-window.innerHeight; if(avail {this.state.loading_status==='failed' &&
}  Loading... : '© thuhole' } /> ); } }