Files
hole_thu_frontend/src/Flows.js
2026-06-24 04:59:53 +08:00

1901 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { PureComponent, useState } from 'react';
import copy from 'copy-to-clipboard';
import { ColorPicker } from './color_picker';
import { split_text, PID_RE } from './text_splitter';
import {
format_time,
Time,
TitleLine,
ClickHandler,
ColoredSpan,
HighlightedMarkdown,
get_push_subscription,
} from './Common';
import './Flows.css';
import LazyLoad, { forceCheck } from 'react-lazyload';
import { TokenCtx, ReplyForm } from './UserAction';
import { API, parse_replies } from './flows_api';
import { cache } from './cache';
import { save_attentions } from './Attention';
import Poll from 'react-polls';
/*
const IMAGE_BASE = 'https://thimg.yecdn.com/';
const IMAGE_BAK_BASE = 'https://img2.thuhole.com/';
*/
const CLICKABLE_TAGS = { a: true, audio: true, button: true };
const PREVIEW_REPLY_COUNT = 10;
// const QUOTE_BLACKLIST=['23333','233333','66666','666666','10086','10000','100000','99999','999999','55555','555555'];
const QUOTE_BLACKLIST = [];
window.LATEST_POST_ID = parseInt(localStorage['_LATEST_POST_ID'], 10) || 0;
const DZ_NAME = '洞主';
function get_search_text(post) {
return [post.text]
.concat((post.comments || []).map((comment) => comment.text))
.join('\n');
}
function check_block(info) {
return (
(((window.config.block_tmp || !window.config.show_all_rooms) &&
info.is_tmp) ||
window.config.block_words_v4.some((word) => info.text.includes(word)) ||
(info.cw &&
window.config.block_words_v4
.concat(window.config.block_cw)
.some((word) => info.cw.includes(word)))) &&
!info.can_del
);
}
function load_single_meta(show_sidebar, token) {
return async (pid, replace = false) => {
let color_picker = new ColorPicker();
let title_elem = '树洞 #' + pid;
show_sidebar(
title_elem,
<div className="box box-tip">正在加载 #{pid}</div>,
replace ? 'replace' : 'push',
);
try {
let single = await API.get_single(pid, token);
single.data.variant = {};
let { data: replies } = await API.load_replies_with_cache(
pid,
token,
color_picker,
parseInt(single.data.reply),
);
show_sidebar(
title_elem,
<FlowSidebar
key={+new Date()}
info={single.data}
replies={replies.data}
attention={replies.attention}
token={token}
show_sidebar={show_sidebar}
color_picker={color_picker}
deletion_detect={localStorage['DELETION_DETECT'] === 'on'}
/>,
'replace',
);
} catch (e) {
console.error(e);
show_sidebar(
title_elem,
<div className="box box-tip">
<p>
<a
href="###"
onClick={() => load_single_meta(show_sidebar, token)(pid, true)}
>
重新加载
</a>
</p>
<p>{'' + e}</p>
</div>,
'replace',
);
}
};
}
class Reply extends PureComponent {
render() {
const {
info,
color_picker,
show_pid,
do_filter_name,
do_delete,
do_report,
do_block,
search_param,
} = this.props;
const author = info.name,
replyText = info.text;
this.color_picker = new ColorPicker();
return (
!check_block(info) && (
<div
className={'flow-reply box'}
style={
info._display_color
? {
'--box-bgcolor-light': info._display_color[0],
'--box-bgcolor-dark': info._display_color[1],
}
: null
}
>
<div className="box-header">
{!!do_filter_name && (
<span
className="reply-header-badge clickable"
onClick={() => {
do_filter_name(info.name);
}}
>
<span className="icon icon-locate" />
</span>
)}
&nbsp;
{<span className="box-header-name">{info.name}</span>}
{info.author_title && (
<span className="box-header-name author-title">{`"${info.author_title}"`}</span>
)}
{info.is_tmp && (
<span className="box-header-name tmp-title">临时账号</span>
)}
{!!do_delete && !!info.can_del && (
<span
className="clickable icon icon-delete op-btn"
onClick={() => {
do_delete('cid', info.cid);
}}
/>
)}
{!!do_block && (
<span
className="clickable icon icon-block op-btn"
onClick={do_block}
/>
)}
{!!do_report && (
<span
className="clickable icon icon-flag op-btn"
onClick={do_report}
/>
)}
{info.blocked_count && (
<span className="danger-info"> {info.blocked_count} </span>
)}
<Time stamp={info.timestamp} short={!do_report} />
&nbsp;
</div>
<div className="box-content">
<HighlightedMarkdown
author={author}
text={replyText}
color_picker={color_picker}
show_pid={show_pid}
search_param={search_param}
/>
</div>
</div>
)
);
}
}
class FlowItem extends PureComponent {
constructor(props) {
super(props);
this.state = {
cw: props.info.cw || '',
};
}
copy_link(event) {
event.preventDefault();
copy(
`${event.target.href}${
this.props.info.cw ? ' 【' + this.props.info.cw + '】' : ''
}\n\n${this.props.info.text}\n` +
`${format_time(new Date(this.props.info.timestamp * 1000))} ${
this.props.info.likenum
}关注 ${this.props.info.reply}回复)\n\n` +
this.props.replies.map((r) => `${r.name}\n${r.text}\n`).join('\n'),
);
}
on_cw_change(event) {
this.setState({
cw: event.target.value,
});
}
render() {
const {
info,
is_quote,
cached,
attention,
do_filter_name,
do_delete,
do_edit_cw,
img_clickable,
color_picker,
show_pid,
do_vote,
do_block,
do_react,
search_param,
} = this.props;
const { cw } = this.state;
const _display_color = color_picker.get(DZ_NAME, info.is_tmp);
return (
<div className={'flow-item' + (is_quote ? ' flow-item-quote' : '')}>
{!!is_quote && (
<div className="quote-tip black-outline">
<div>
<span className="icon icon-quote" />
</div>
{/*<div>*/}
{/* <small>提到</small>*/}
{/*</div>*/}
</div>
)}
<div
className="box box-post"
style={{
'--box-bgcolor-light': _display_color[0],
'--box-bgcolor-dark': _display_color[1],
}}
>
<div className="box-post-main">
{!!window.LATEST_POST_ID &&
parseInt(info.pid, 10) > window.LATEST_POST_ID && (
<div className="flow-item-dot" />
)}
{!!attention && !cached && <div className="flow-item-dot" />}
<div className="box-header">
{!!do_filter_name && (
<span
className="reply-header-badge clickable"
onClick={() => {
do_filter_name(DZ_NAME);
}}
>
<span className="icon icon-locate" />
</span>
)}
{!do_react && (
<span className="box-header-badge">
<span className="icon icon-up-and-down" />
<span style={{ verticalAlign: 'super' }}>
{info.up_votes - info.down_votes}
</span>
</span>
)}
{!!parseInt(info.likenum, 10) && (
<span className="box-header-badge">
{info.likenum}&nbsp;
<span
className={'icon icon-' + (attention ? 'star-ok' : 'star')}
/>
</span>
)}
{!!parseInt(info.reply, 10) && (
<span className="box-header-badge">
{info.reply}&nbsp;
<span className="icon icon-comment" />
</span>
)}
<code className="box-id">
<a href={'##' + info.pid} onClick={this.copy_link.bind(this)}>
#{info.pid}
{info.room_id !== parseInt(window.config.room) && (
<> @{info.room_id}</>
)}
</a>
</code>
&nbsp;
{info.author_title && (
<span className="box-header-name author-title">{`"${info.author_title}"`}</span>
)}
{info.is_tmp && (
<span className="box-header-name tmp-title">临时账号</span>
)}
{info.is_reported && <span className="danger-info"> R </span>}
{!!do_delete && !!info.can_del && (
<span
className="clickable icon icon-delete op-btn"
onClick={() => {
do_delete('pid', info.pid);
}}
/>
)}
{!!do_block && (
<span
className="clickable icon icon-block op-btn"
onClick={do_block}
/>
)}
{info.dangerous_user && (
<span className="danger-info"> {info.dangerous_user} </span>
)}
{info.blocked_count && (
<span className="danger-info"> {info.blocked_count} </span>
)}
{info.cw !== null && (!do_edit_cw || !info.can_del) && (
<span className="box-header-cw">{info.cw}</span>
)}
{!!do_edit_cw && !!info.can_del && (
<div className="box-header-cw-edit clickable">
<input
type="text"
value={cw}
maxLength="32"
placeholder="编辑折叠警告"
onChange={this.on_cw_change.bind(this)}
/>
<button
type="button"
onClick={(e) => do_edit_cw(cw, info.pid)}
>
更新
</button>
</div>
)}
{!info.allow_search && <span className="icon icon-lock" />}
<Time stamp={info.timestamp} short={!img_clickable} />
</div>
{!!info.hot_score && (
<span className="box-header">hot score: {info.hot_score}</span>
)}
<div className="box-content">
<HighlightedMarkdown
text={info.text}
color_picker={color_picker}
show_pid={show_pid}
search_param={search_param}
/>
</div>
{info.poll && (
<div className="box-poll">
<Poll
key={info.poll.answers.map((a) => a.votes).join('_')}
question={''}
answers={info.poll.answers}
onVote={do_vote}
customStyles={{ theme: 'cyan' }}
noStorage={true}
vote={
localStorage['VOTE_RECORD:' + info.pid] || info.poll.vote
}
/>
</div>
)}
{!!(attention && info.variant.latest_reply) && (
<p className="box-footer">
最新回复{' '}
<Time stamp={info.variant.latest_reply} short={false} />
</p>
)}
</div>
{do_react && (
<div className="box-post-vote">
<span
className="clickable"
onClick={() => do_react(info.reaction_status === 1 ? 0 : 1)}
>
<svg
className={
info.reaction_status === 1
? 'vote-icon-active'
: 'vote-icon-inactive'
}
width="24"
height="24"
viewBox="0 0 36 36"
>
<path d="M2 25h32L18 9 2 25Z" />
</svg>
</span>
<div className="vote-num">{info.up_votes}</div>
<hr />
<span
className="clickable"
onClick={() =>
do_react && do_react(info.reaction_status === -1 ? 0 : -1)
}
>
<svg
className={
info.reaction_status === -1
? 'vote-icon-active'
: 'vote-icon-inactive'
}
width="24"
height="24"
viewBox="0 0 36 36"
>
<path d="M2 11h32L18 27 2 11Z"></path>
</svg>
</span>
</div>
)}
</div>
</div>
);
}
}
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,
likenum: '' + json.likenum,
})
: 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,
});
});
}
set_reaction(reaction_status) {
console.log(reaction_status);
const prev_info = this.state.info;
const pid = prev_info.pid;
API.set_reaction(pid, reaction_status, this.props.token).then((json) => {
this.setState({
info: Object.assign({}, prev_info, json.data),
});
// TODO: save to localStore
this.syncState({
info: Object.assign({}, prev_info, json.data),
});
});
}
toggle_attention() {
const prev_info = this.state.info;
const pid = prev_info.pid;
API.set_attention(pid, !this.state.attention, this.props.token)
.then((json) => {
this.setState({
attention: json.attention,
info: Object.assign({}, prev_info, {
likenum: '' + json.likenum,
}),
});
let saved_attentions = window.saved_attentions;
if (json.attention && !saved_attentions.includes(pid)) {
saved_attentions.unshift(pid);
} else if (!json.attention && saved_attentions.includes(pid)) {
const idx = saved_attentions.indexOf(pid);
saved_attentions.splice(idx, 1);
}
window.saved_attentions = saved_attentions;
save_attentions();
this.syncState({
attention: json.attention,
info: Object.assign({}, prev_info, {
likenum: '' + json.likenum,
}),
});
})
.catch((e) => {
this.setState({
loading_status: 'done',
});
alert('设置关注失败\n' + e);
console.error(e);
});
}
toggle_notification() {
console.log('set notifi');
get_push_subscription().then((sc) => {
if (!sc) {
alert('浏览器不支持消息推送,或无法连接到推送服务');
return;
}
let sc_data = JSON.parse(JSON.stringify(sc));
API.set_notification(
this.state.info.pid,
true,
sc_data.endpoint,
sc_data.keys.auth,
sc_data.keys.p256dh,
this.props.token,
).then((json) => {
// TODO
});
});
}
do_vote(vote) {
this.setState({
loading_status: 'loading',
error_msg: null,
});
API.add_vote(vote, this.state.info.pid, this.props.token)
.then((json) => {
if (json.code !== 0) return;
localStorage['VOTE_RECORD:' + this.state.info.pid] = vote;
this.setState(
(prev, props) => ({
info: Object.assign({}, prev.info, { poll: json.data }),
loading_status: 'done',
}),
() => {
this.syncState({
info: this.state.info,
});
},
);
})
.catch((e) => {
console.error(e);
this.setState({
loading_status: 'done',
error_msg: '' + e,
});
});
}
report(event, text = '') {
console.log(text);
let reason = prompt(`举报 #${this.state.info.pid} 的理由:`, text);
if (!reason) return;
let should_hide = window.confirm('是否认为此洞应该被删除或隐藏?');
if (reason !== null) {
API.report(this.state.info.pid, reason, should_hide, this.props.token)
.then((json) => {
alert('举报成功');
})
.catch((e) => {
alert('举报失败\n' + e);
console.error(e);
});
}
}
block(name, type, id, on_complete) {
if (window.confirm(`确定拉黑${name}吗?后续将不会收到其发布的任何内容`)) {
API.block(type, id, this.props.token)
.then((json) => {
let data = json.data;
alert(`操作成功,当前拉黑数 ${data.curr}`);
!!on_complete && on_complete();
})
.catch((e) => {
alert('拉黑失败\n' + e);
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 }), forceCheck);
}
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);
}
}
}
make_do_delete(token, on_complete = null) {
const do_delete = (type, id) => {
let note = prompt(`将删除${type}=${id}, 备注:`, '(无)');
if (note !== null) {
API.del(type, id, note, token)
.then((json) => {
alert('删除成功');
on_complete();
})
.catch((e) => {
alert('删除失败\n' + e);
console.error(e);
});
}
};
return do_delete;
}
do_edit_cw(cw, id) {
API.update_cw(cw, id, this.props.token)
.then((json) => {
this.setState(
{
info: Object.assign({}, this.state.info, { cw: cw }),
},
() => {
this.syncState({
info: this.state.info,
});
},
);
alert('已更新');
})
.catch((e) => {
alert('更新失败\n' + e);
console.error(e);
});
}
render() {
if (this.state.loading_status === 'loading')
return <p className="box box-tip">加载中</p>;
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();
// may not need key, for performance
// 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 : (
<ClickHandler
callback={(e) => {
this.show_reply_bar('', e);
}}
>
<FlowItem
info={this.state.info}
attention={this.state.attention}
img_clickable={true}
color_picker={this.color_picker}
show_pid={show_pid}
search_param={this.props.search_param}
replies={this.state.replies}
set_variant={(variant) => {
this.set_variant(null, variant);
}}
do_filter_name={
replies_cnt[DZ_NAME] > 1 ? this.set_filter_name.bind(this) : null
}
do_delete={this.make_do_delete(this.props.token, () => {
window.location.reload();
})}
do_edit_cw={this.do_edit_cw.bind(this)}
do_vote={this.do_vote.bind(this)}
do_block={() => {
this.block('洞主', 'post', this.state.info.pid, () => {
window.location.reload();
});
}}
do_react={this.set_reaction.bind(this)}
/>
</ClickHandler>
);
return (
<div className="flow-item-row sidebar-flow-item">
<div className="box box-tip">
{!!this.props.token && (
<span>
<a href="###" onClick={this.report.bind(this)}>
<span className="icon icon-flag" />
<label>举报</label>
</a>
&nbsp;&nbsp;
</span>
)}
<a href="###" onClick={this.load_replies.bind(this)}>
<span className="icon icon-refresh" />
<label>刷新</label>
</a>
{(this.state.replies.length >= 1 || this.state.rev) && (
<span>
&nbsp;&nbsp;
<a href="###" onClick={this.toggle_rev.bind(this)}>
<span className="icon icon-order-rev" />
<label>{this.state.rev ? '还原' : '逆序'}</label>
</a>
</span>
)}
{!!this.props.token && (
<span>
&nbsp;&nbsp;
<a href="###" onClick={() => this.toggle_attention()}>
{this.state.attention ? (
<span>
<span className="icon icon-star-ok" />
<label>已关注</label>
</span>
) : (
<span>
<span className="icon icon-star" />
<label>未关注</label>
</span>
)}
</a>
</span>
)}
{!!this.props.token && !!this.state.attention && (
<span style={{ display: 'none' }}>
&nbsp;&nbsp;
<a href="###" onClick={() => this.toggle_notification()}>
<span>
<span className="icon icon-bell" />
<label>提醒</label>
</span>
</a>
</span>
)}
</div>
{!!this.state.filter_name && (
<div className="box box-tip flow-item filter-name-bar">
<p>
<span style={{ float: 'left' }}>
<a
href="###"
onClick={() => {
this.set_filter_name(null);
}}
>
还原
</a>
</span>
<span className="icon icon-locate" />
&nbsp;当前只看&nbsp;
<ColoredSpan
colors={this.color_picker.get(this.state.filter_name)}
>
{this.state.filter_name}
</ColoredSpan>
</p>
</div>
)}
{!this.state.rev && main_thread_elem}
{!!this.state.error_msg && (
<div className="box box-tip flow-item">
<p>加载失败</p>
<p>{this.state.error_msg}</p>
</div>
)}
{this.props.deletion_detect &&
parseInt(this.state.info.reply) > this.state.replies.length &&
!!this.state.replies.length && (
<div className="box box-tip flow-item box-danger">
{parseInt(this.state.info.reply) - this.state.replies.length}{' '}
条回复被删除
</div>
)}
{replies_to_show.map(
(reply, i) =>
(reply.can_del || !reply.is_blocked) && (
<LazyLoad
key={i}
offset={1500}
height="5em"
overflow={true}
once={true}
>
<ClickHandler
callback={(e) => {
this.show_reply_bar(reply.name, e);
}}
>
<Reply
info={reply}
color_picker={this.color_picker}
show_pid={show_pid}
search_param={this.props.search_param}
set_variant={(variant) => {
this.set_variant(reply.cid, variant);
}}
do_filter_name={
replies_cnt[reply.name] > 1
? this.set_filter_name.bind(this)
: null
}
do_delete={this.make_do_delete(
this.props.token,
this.load_replies.bind(this),
)}
do_block={() => {
this.block(
reply.name,
'comment',
reply.cid,
this.load_replies.bind(this),
);
}}
do_report={(e) => {
this.report(
e,
`评论区${reply.name}评论id ${reply.cid}`,
);
}}
/>
</ClickHandler>
</LazyLoad>
),
)}
{this.state.rev && main_thread_elem}
{this.props.token ? (
<ReplyForm
pid={this.state.info.pid}
token={this.props.token}
area_ref={this.reply_ref}
on_complete={this.load_replies.bind(this)}
/>
) : (
<div className="box box-tip flow-item">登录后可以回复树洞</div>
)}
</div>
);
}
}
class FlowItemRow extends PureComponent {
constructor(props) {
super(props);
this.needFold =
props.info.cw &&
window.config.whitelist_cw.indexOf('*') === -1 &&
window.config.whitelist_cw.indexOf(props.info.cw) === -1 &&
(props.mode === 'list' || this.props.is_quote);
this.has_block_words = check_block(props.info);
this.too_many_downvotes =
props.info.down_votes > 10 &&
props.info.down_votes > 2 * props.info.up_votes;
this.color_picker = new ColorPicker();
this.state = {
replies: props.info.comments
? parse_replies(props.info.comments, this.color_picker)
: [],
reply_status: 'done',
reply_error: null,
info: Object.assign({}, props.info, { variant: {} }),
attention: props.info.attention,
cached: true, // default no display anything
};
}
componentDidMount() {
// cache from getlist, so always to this to update attention
if (!this.props.info.comments) {
//if (true || parseInt(this.state.info.reply, 10)) {
this.load_replies(null, /*update_count=*/ false);
}
}
// reveal() {
// this.setState({ hidden: false });
// }
do_vote(vote) {
const { info } = this.state;
let current_poll = info.poll;
current_poll.vote = vote;
current_poll.answers.forEach((ans) => {
if (ans.option === vote) ans.votes += 1;
});
this.setState(
{
info: Object.assign({}, info, { poll: current_poll }),
},
() => {
API.add_vote(vote, info.pid, this.props.token)
.then((json) => {
if (json.code !== 0) return;
localStorage['VOTE_RECORD:' + info.pid] = vote;
this.setState((prev, props) => ({
info: Object.assign({}, prev.info, { poll: json.data }),
}));
})
.catch((e) => {
console.error(e);
alert('投票失败');
});
},
);
}
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(({ data: json, cached }) => {
//console.log('>> update', json, json.attention);
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,
cached,
}),
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,
<FlowSidebar
key={+new Date()}
info={this.state.info}
search_param={this.props.search_param}
replies={this.state.replies}
attention={this.state.attention}
sync_state={this.setState.bind(this)}
token={this.props.token}
show_sidebar={this.props.show_sidebar}
color_picker={this.color_picker}
deletion_detect={this.props.deletion_detect}
/>,
);
}
render() {
const { show_sidebar, token, search_param, is_quote, mode, submode } =
this.props;
let show_pid = load_single_meta(show_sidebar, token, [this.state.info.pid]);
let hl_rules = [['pid', PID_RE]];
let parts = split_text(this.state.info.text, hl_rules);
//console.log('hl:', parts,this.state.info.pid);
let quote_id = null;
if (!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) < parseInt(this.state.info.pid)
) {
if (quote_id === null) quote_id = parseInt(content);
else {
quote_id = null;
break;
}
}
}
if (mode === 'list' && submode === 1) {
this.state.replies.reverse();
}
let replies_to_show = this.state.replies.slice(0, PREVIEW_REPLY_COUNT);
let res = (
<div
className={
'flow-item-row flow-item-row-with-prompt' +
(is_quote ? ' flow-item-row-quote' : '')
}
onClick={(event) => {
if (!CLICKABLE_TAGS[event.target.tagName.toLowerCase()])
this.show_sidebar();
}}
>
<FlowItem
info={this.state.info}
attention={this.state.attention}
img_clickable={false}
is_quote={this.props.is_quote}
color_picker={this.color_picker}
show_pid={show_pid}
replies={this.state.replies}
cached={this.state.cached}
search_param={search_param}
do_vote={this.do_vote.bind(this)}
/>
<div className="flow-reply-row">
{this.state.reply_status === 'loading' && (
<div className="box box-tip">加载中</div>
)}
{this.state.reply_status === 'failed' && (
<div className="box box-tip">
<p>
<a
href="###"
onClick={() => {
this.load_replies();
}}
>
重新加载评论
</a>
</p>
<p>{this.state.reply_error}</p>
</div>
)}
{replies_to_show.map(
(reply) =>
(reply.can_del || !reply.is_blocked) && (
<Reply
key={reply.cid}
info={reply}
color_picker={this.color_picker}
show_pid={show_pid}
search_param={search_param}
/>
),
)}
{this.state.replies.length > PREVIEW_REPLY_COUNT && (
<div className="box box-tip">
还有 {this.state.replies.length - PREVIEW_REPLY_COUNT}
</div>
)}
</div>
</div>
);
if (this.needFold || this.has_block_words || this.too_many_downvotes) {
return (
(!this.has_block_words || mode !== 'list') && (
<div
className={
'flow-item-row flow-item-row-with-prompt' +
(is_quote ? ' flow-item-row-quote' : '')
}
onClick={(event) => {
if (!CLICKABLE_TAGS[event.target.tagName.toLowerCase()])
this.show_sidebar();
}}
>
<div
className={
'flow-item' + (this.props.is_quote ? ' flow-item-quote' : '')
}
>
{!!this.props.is_quote && (
<div className="quote-tip black-outline">
<div>
<span className="icon icon-quote" />
</div>
{/*<div>*/}
{/* <small>提到</small>*/}
{/*</div>*/}
</div>
)}
<div className="box">
<div className="box-header">
{!!this.props.do_filter_name && (
<span
className="reply-header-badge clickable"
onClick={() => {
this.props.do_filter_name(DZ_NAME);
}}
>
<span className="icon icon-locate" />
</span>
)}
<code className="box-id">#{this.props.info.pid}</code>
&nbsp;
{this.props.info.author_title && (
<span className="box-header-name author-title">{`"${this.props.info.author_title}"`}</span>
)}
{this.props.info.is_tmp && (
<span className="box-header-name tmp-title">临时账号</span>
)}
{this.props.info.cw !== null && (
<span className="box-header-cw">{this.props.info.cw}</span>
)}
<Time stamp={this.props.info.timestamp} short={true} />
<span className="box-header-badge">
{this.needFold && ' 已折叠'}
{this.has_block_words && ' 已屏蔽'}
{this.too_many_downvotes && ' 争议较大'}
</span>
<div style={{ clear: 'both' }} />
</div>
</div>
</div>
</div>
)
);
}
return quote_id ? (
<div>
{res}
<FlowItemQuote
pid={quote_id}
show_sidebar={this.props.show_sidebar}
token={this.props.token}
deletion_detect={this.props.deletion_detect}
/>
</div>
) : (
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 (
<div className="aux-margin">
<div className="box box-tip">
<span className="icon icon-loading" />
提到了 #{this.props.pid}
</div>
</div>
);
else if (this.state.loading_status === 'error')
return (
<div className="aux-margin">
<div className="box box-tip">
<p>
<a href="###" onClick={this.load.bind(this)}>
重新加载
</a>
</p>
<p>{this.state.error_msg}</p>
</div>
</div>
);
// 'done'
else
return (
<FlowItemRow
info={this.state.info}
mode={this.props.mode}
show_sidebar={this.props.show_sidebar}
token={this.props.token}
is_quote={true}
deletion_detect={this.props.deletion_detect}
/>
);
}
}
function FlowChunk(props) {
return (
<TokenCtx.Consumer>
{({ value: token }) => (
<div className="flow-chunk">
{!!props.title && <TitleLine text={props.title} />}
{props.list.map(
(info, ind) =>
(info.can_del || !info.is_blocked) && (
<LazyLoad
key={info.key || info.pid}
offset={500}
height="15em"
hiddenIfInvisible={false}
>
<div>
{!!(
props.deletion_detect &&
props.mode === 'list' &&
ind &&
props.list[ind - 1].pid - info.pid > 1
) && (
<div className="flow-item-row">
<div className="box box-tip flow-item box-danger">
{props.list[ind - 1].pid - info.pid - 1} 条被删除
</div>
</div>
)}
<FlowItemRow
info={info}
mode={props.mode}
submode={props.submode}
show_sidebar={props.show_sidebar}
token={token}
deletion_detect={props.deletion_detect}
search_param={props.search_param}
/>
</div>
</LazyLoad>
),
)}
</div>
)}
</TokenCtx.Consumer>
);
}
function Announcement(props) {
const [show, setShow] = useState(true);
function do_close() {
window.LAST_ANN = props.text;
localStorage['LAST_ANN'] = props.text;
setShow(false);
}
return (
show && (
<div className="flow-item-row">
<div className="flow-item">
<div className="box">
<div className="box-header announcement-header">
<a href="###" className="no-underline" onClick={do_close}>
&nbsp;
<span className="icon icon-close" />
&nbsp;
</a>
</div>
<div className="box-content">
<HighlightedMarkdown
text={props.text}
show_pid={props.show_pid}
/>
</div>
</div>
</div>
</div>
)
);
}
export class Flow extends PureComponent {
constructor(props) {
super(props);
let submode = window[props.mode.toUpperCase() + '_SUBMODE_BACKUP'];
if (submode === undefined) {
submode =
(props.mode === 'list' && window.config.by_c) || props.mode === 'search'
? 1
: 0;
}
this.state = {
submode: submode,
announcement: window.ANN,
};
}
get_submode_names(mode) {
switch (mode) {
case 'list':
return ['最新', '回复', '热门', '随机', '典藏'];
case 'attention':
return ['线上关注', '本地收藏'];
case 'search':
return ['Tag搜索', '全文搜索', '头衔'];
default:
return [];
}
}
set_submode(submode) {
window[this.props.mode.toUpperCase() + '_SUBMODE_BACKUP'] = submode;
this.setState({
submode: submode,
});
}
update_announcement(text) {
if (text !== this.state.announcement) {
window.ANN = text;
localStorage['ANN'] = text;
this.setState({
announcement: text,
});
}
}
render() {
const { submode, announcement } = this.state;
const { mode, show_sidebar, search_text, token } = this.props;
const submode_names = this.get_submode_names(mode);
return (
<>
{announcement && window.LAST_ANN !== announcement && (
<Announcement
text={announcement}
show_pid={load_single_meta(show_sidebar, token)}
/>
)}
<div className="aux-margin flow-submode-choice">
{submode_names.map((name, idx) => (
<a
href="###"
key={idx}
className={submode === idx ? 'choiced' : ''}
onClick={this.set_submode.bind(this, idx)}
>
{name}
</a>
))}
</div>
<SubFlow
key={submode}
show_sidebar={show_sidebar}
update_announcement={(text) => this.update_announcement(text)}
mode={mode}
submode={submode}
search_text={search_text}
token={token}
/>
</>
);
}
}
class SubFlow extends PureComponent {
constructor(props) {
super(props);
this.state = {
mode: props.mode,
search_param: props.search_text,
loaded_pages: 0,
chunks: {
title: '',
data: [],
},
local_attention_text: null,
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) {
const { mode, search_param } = this.state;
const { token, submode, update_announcement } = this.props;
console.log('fetching page', page);
cache();
if (mode === 'list') {
API.get_list(page, token, submode)
.then((json) => {
if (page === 1 && json.data.length) {
// update latest_post_id
let max_id = window.LATEST_POST_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;
}
if (page === 1) {
window.IS_ADMIN = json.is_admin;
window.IS_CANDIDATE = json.is_candidate;
window.TITLE = json.custom_title;
window.AUTO_BLCOK = json.auto_block_rank;
if (
json.custom_title &&
window.TITLE_SECRET !== json.title_secret
) {
window.TITLE_SECRET = json.title_secret;
localStorage['TITLE_SECRET'] = json.title_secret;
}
}
json.data.forEach((x) => {
if (x.comments) {
let comment_json = {
code: 0,
attention: x.attention,
data: x.comments,
};
//console.log('My cache', comment_json, x.pid, x.reply)
cache().put(x.pid, parseInt(x.reply, 10), comment_json);
}
});
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',
}),
() => {
update_announcement(json.announcement);
},
);
})
.catch(failed);
} else if (mode === 'search' && search_param) {
API.get_search(page, search_param, token, submode)
.then((json) => {
const finished = json.data.length === 0;
this.setState((prev, props) => ({
chunks: {
title: 'Result for "' + search_param + '"',
data: prev.chunks.data.concat(json.data),
},
mode: finished ? 'search_finished' : 'search',
loading_status: 'done',
}));
})
.catch(failed);
} else if (mode === 'single') {
const pid = parseInt(this.state.search_param.substr(1), 10);
API.get_single(pid, this.props.token)
.then((json) => {
let x = json.data;
if (x.comments) {
let comment_json = {
code: 0,
attention: x.attention,
data: x.comments,
};
//console.log('My cache', comment_json, x.pid, x.reply)
cache().put(x.pid, parseInt(x.reply, 10), comment_json);
}
this.setState({
chunks: {
title: 'PID = ' + pid,
data: [json.data],
},
mode: 'single_finished',
loading_status: 'done',
});
})
.catch(failed);
} else if (mode === 'attention') {
let use_search = !!this.state.search_param;
let use_regex = use_search && !!this.state.search_param.match(/\/.+\//);
let regex_search = /.+/;
if (use_regex) {
try {
regex_search = new RegExp(
this.state.search_param.slice(1, -1),
'i',
);
} catch (e) {
alert(`请检查正则表达式合法性!\n${e}`);
regex_search = /.+/;
}
}
console.log(use_search, use_regex);
if (this.props.submode === 0) {
API.get_attention(this.props.token)
.then((json) => {
this.setState({
chunks: {
title: `${
use_search
? use_regex
? `Result for RegEx ${regex_search.toString()} in `
: `Result for "${this.state.search_param}" in `
: ''
}Attention List`,
data: !use_search
? json.data
: !use_regex
? json.data.filter((post) => {
const search_text = get_search_text(post).toLowerCase();
return this.state.search_param
.split(' ')
.every((keyword) =>
search_text.includes(keyword.toLowerCase()),
);
}) // Not using regex
: json.data.filter(
(post) => !!get_search_text(post).match(regex_search),
), // Using regex
},
mode: 'attention_finished',
loading_status: 'done',
});
if (!use_search) {
window.saved_attentions = Array.from(
new Set([
...window.saved_attentions,
...json.data.map((post) => post.pid),
]),
).sort((a, b) => b - a);
save_attentions();
}
})
.catch(failed);
} else if (this.props.submode === 1) {
const PERPAGE = 50;
let pids = window.saved_attentions
.sort((a, b) => b - a)
.slice((page - 1) * PERPAGE, page * PERPAGE);
if (pids.length) {
API.get_multi(pids, this.props.token)
.then((json) => {
json.data.forEach((x) => {
if (x.comments) {
let comment_json = {
code: 0,
attention: x.attention,
data: x.comments,
};
//console.log('My cache', comment_json, x.pid, x.reply)
cache().put(x.pid, parseInt(x.reply, 10), comment_json);
}
});
this.setState((prev, props) => ({
chunks: {
title: `${
use_search
? use_regex
? `Result for RegEx ${regex_search.toString()} in `
: `Result for "${this.state.search_param}" in `
: ''
}Attention List: Local`,
data: prev.chunks.data.concat(
!use_search
? json.data
: !use_regex
? json.data.filter((post) => {
const search_text =
get_search_text(post).toLowerCase();
return this.state.search_param
.split(' ')
.every((keyword) =>
search_text.includes(keyword.toLowerCase()),
);
})
: json.data.filter(
(post) =>
!!get_search_text(post).match(regex_search),
),
),
},
loading_status: 'done',
}));
})
.catch(failed);
} else {
console.log('local attention finished');
this.setState({
loading_status: 'done',
mode: 'attention_finished',
});
return;
}
}
} 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 < window.innerHeight && this.state.loading_status === 'done')
this.load_page(this.state.loaded_pages + 1);
}
}
componentDidMount() {
this.load_page(1);
window.addEventListener('scroll', this.on_scroll_bound);
window.addEventListener('resize', this.on_scroll_bound);
}
componentWillUnmount() {
window.removeEventListener('scroll', this.on_scroll_bound);
window.removeEventListener('resize', this.on_scroll_bound);
}
trunc_string(s, max_len) {
return s.substr(0, max_len) + (s.length > max_len ? '...' : '');
}
gen_export() {
this.setState({
can_export: false,
local_attention_text: JSON.parse(localStorage['saved_attentions'] || '[]')
.map((pid) => `#${pid}`)
.join(' '),
});
}
render() {
const should_deletion_detect = localStorage['DELETION_DETECT'] === 'on';
const { mode, chunks, local_attention_text, search_param } = this.state;
const { submode, show_sidebar } = this.props;
return (
<div className="flow-container">
{mode === 'attention' &&
submode === 1 &&
local_attention_text === null && (
<button
className="export-btn"
type="button"
onClick={this.gen_export.bind(this)}
>
导出/导入
</button>
)}
{local_attention_text && (
<LocalAttentionEditer init_text={local_attention_text} />
)}
<FlowChunk
title={chunks.title}
list={chunks.data}
mode={mode}
submode={submode}
search_param={search_param || null}
show_sidebar={show_sidebar}
deletion_detect={should_deletion_detect}
/>
{this.state.loading_status === 'failed' && (
<div className="aux-margin">
<div className="box box-tip">
<p>
<a
href="###"
onClick={() => {
this.load_page(this.state.loaded_pages + 1);
}}
>
重新加载
</a>
</p>
<p>{this.state.error_msg}</p>
</div>
</div>
)}
<TitleLine
text={
this.state.loading_status === 'loading' ? (
<span>
<span className="icon icon-loading" />
&nbsp;Loading...
</span>
) : (
'🄯 2020 copyleft: hole_thu'
)
}
/>
</div>
);
}
}
function LocalAttentionEditer(props) {
const [text, setText] = useState(props.init_text);
const PID_RE = /#(\d+)/g;
const save = () => {
let pids = [...text.matchAll(PID_RE)]
.map((m) => parseInt(m[1]))
.sort((a, b) => b - a);
console.log(pids);
window.saved_attentions = pids;
save_attentions();
setText(pids.map((pid) => `#${pid}`).join(' '));
};
return (
<div className="box">
<textarea
className="export-textarea"
value={text}
onChange={(event) => setText(event.target.value)}
/>
<button className="export-btn" type="button" onClick={save}>
保存
</button>
</div>
);
}