THU Hole react frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

752 lines
29 KiB

import React, {Component, PureComponent} from 'react';
import {API_BASE, SafeTextarea, PromotionBar, HighlightedMarkdown} from './Common';
import {MessageViewer} from './Message';
import {LoginPopup} from './infrastructure/widgets';
import {ConfigUI} from './Config';
import fixOrientation from 'fix-orientation';
import copy from 'copy-to-clipboard';
import {cache} from './cache';
import {API_VERSION_PARAM, THUHOLE_API_ROOT, API, get_json, token_param} from './flows_api';
import './UserAction.css';
import {ColorPicker} from "./color_picker";
const BASE64_RATE=4/3;
const MAX_IMG_DIAM=8000;
const MAX_IMG_PX=5000000;
const MAX_IMG_FILESIZE=450000*BASE64_RATE;
export const TokenCtx=React.createContext({
value: null,
set_value: ()=>{},
});
// class LifeInfoBox extends Component {
// constructor(props) {
// super(props);
// if(!window._life_info_cache)
// window._life_info_cache={};
// this.CACHE_TIMEOUT_S=15;
// this.state={
// today_info: this.cache_get('today_info'),
// card_balance: this.cache_get('card_balance'),
// net_balance: this.cache_get('net_balance'),
// mail_count: this.cache_get('mail_count'),
// };
// this.INTERNAL_NETWORK_FAILURE='_network_failure';
// this.API_NAME={
// today_info: 'hole/today_info',
// card_balance: 'isop/card_balance',
// net_balance: 'isop/net_balance',
// mail_count: 'isop/mail_count',
// };
// }
//
// cache_get(key) {
// let cache_item=window._life_info_cache[key];
// if(!cache_item || (+new Date())-cache_item[0]>1000*this.CACHE_TIMEOUT_S)
// return null;
// else
// return cache_item[1];
// }
// cache_set(key,value) {
// if(!window._life_info_cache[key] || window._life_info_cache[key][1]!==value)
// window._life_info_cache[key]=[+new Date(),value];
// }
//
// load(state_key) {
// this.setState({
// [state_key]: null,
// },()=>{
// fetch(
// PKUHELPER_ROOT+'api_xmcp/'+this.API_NAME[state_key]
// +'?user_token='+encodeURIComponent(this.props.token)
// +API_VERSION_PARAM()
// )
// .then(get_json)
// .then((json)=>{
// //console.log(json);
// this.setState({
// [state_key]: json,
// });
// })
// .catch((e)=>{
// this.setState({
// [state_key]: {
// errMsg: '网络错误 '+e,
// errCode: this.INTERNAL_NETWORK_FAILURE,
// success: false,
// }
// });
// })
// });
// }
//
// componentDidMount() {
// ['today_info','card_balance','net_balance','mail_count'].forEach((k)=>{
// if(!this.state[k])
// this.load(k);
// });
// }
//
// reload_all() {
// ['today_info','card_balance','net_balance','mail_count'].forEach((k)=>{
// this.load(k);
// });
// }
//
// render_line(state_key,title,value_fn,action,url_fn,do_login) {
// let s=this.state[state_key];
// if(!s)
// return (
// <tr>
// <td>{title}</td>
// <td>加载中……</td>
// <td />
// </tr>
// );
// else if(!s.success) {
// let type='加载失败';
// if(s.errCode===this.INTERNAL_NETWORK_FAILURE)
// type='网络错误';
// else if(['E01','E02','E03'].indexOf(s.errCode)!==-1)
// type='授权失效';
//
// let details=JSON.stringify(s);
// if(s.errMsg)
// details=s.errMsg;
// else if(s.error)
// details=s.error;
//
// return (
// <tr>
// <td>{title}</td>
// <td className="life-info-error">
// <a onClick={()=>alert(details)}>{type}</a>
// </td>
// <td>
// {type==='授权失效' ?
// <a onClick={do_login}>
// <span className="icon icon-forward" />&nbsp;重新登录
// </a> :
// <a onClick={()=>this.load(state_key)}>
// <span className="icon icon-forward" />&nbsp;重试
// </a>
// }
// </td>
// </tr>
// )
// }
// else {
// this.cache_set(state_key,s);
//
// return (
// <tr>
// <td>{title}</td>
// <td>{value_fn(s)}</td>
// <td>
// <a href={url_fn(s)} target="_blank">
// <span className="icon icon-forward" />&nbsp;{action}
// </a>
// </td>
// </tr>
// );
// }
// }
//
// render() {
// return (
// <LoginPopup token_callback={(t)=>{
// this.props.set_token(t);
// this.reload_all();
// }}>{(do_login)=>(
// <div className="box">
// <table className="life-info-table">
// <tbody>
// {this.render_line(
// 'today_info',
// '今日',(s)=>s.info,
// '校历',(s)=>s.schedule_url,
// do_login,
// )}
// {this.render_line(
// 'card_balance',
// '校园卡',(s)=>`余额¥${s.balance.toFixed(2)}`,
// '充值',()=>'https://virtualprod.alipay.com/educate/educatePcRecharge.htm?schoolCode=PKU&schoolName=',
// do_login,
// )}
// {this.render_line(
// 'net_balance',
// '网费',(s)=>`余额¥${s.balance.toFixed(2)}`,
// '充值',()=>'https://its.pku.edu.cn/epay.jsp',
// do_login,
// )}
// {this.render_line(
// 'mail_count',
// '邮件',(s)=>`未读 ${s.count} 封`,
// '查看',()=>'https://mail.pku.edu.cn/',
// do_login,
// )}
// </tbody>
// </table>
// </div>
// )}</LoginPopup>
// )
// }
// }
export function InfoSidebar(props) {
return (
<div>
<PromotionBar />
<LoginForm show_sidebar={props.show_sidebar} />
<div className="box list-menu">
<a onClick={()=>{props.show_sidebar(
'设置',
<ConfigUI />
)}}>
<span className="icon icon-settings" /><label>设置</label>
</a>
&nbsp;&nbsp;
<a href="https://thuhole.com/policy.html" target="_blank">
<span className="icon icon-textfile" /><label>树洞规范试行</label>
</a>
&nbsp;&nbsp;
<a href="https://github.com/thuhole/thuhole-go-backend/issues" target="_blank">
<span className="icon icon-github" /><label>意见反馈</label>
</a>
</div>
<div className="box help-desc-box">
<p>
<a onClick={()=>{
if('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations()
.then((registrations)=>{
for(let registration of registrations) {
console.log('unregister',registration);
registration.unregister();
}
});
}
cache().clear();
setTimeout(()=>{
window.location.reload(true);
},200);
}}>强制检查更新</a>
当前版本{process.env.REACT_APP_BUILD_INFO||'---'} {process.env.NODE_ENV} 会自动在后台检查更新并在下次访问时更新
</p>
</div>
<div className="box help-desc-box">
<p>
联系我们thuhole at protonmail dot com
</p>
</div>
<div className="box help-desc-box">
<p>
T大树洞 网页版 by @thuhole
基于&nbsp;
<a href="https://www.gnu.org/licenses/gpl-3.0.zh-cn.html" target="_blank">GPLv3</a>
&nbsp;协议在 <a href="https://github.com/thuhole/webhole" target="_blank">GitHub</a>
</p>
<p>
T大树洞 网页版的诞生离不开&nbsp;
<a href="https://github.com/pkuhelper-web/webhole" target="_blank" rel="noopener">P大树洞网页版 by @xmcp</a>
<a href="https://reactjs.org/" target="_blank" rel="noopener">React</a>
<a href="https://icomoon.io/#icons" target="_blank" rel="noopener">IcoMoon</a>
&nbsp;等开源项目
</p>
<p>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
</p>
<p>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the&nbsp;
<a href="https://www.gnu.org/licenses/gpl-3.0.zh-cn.html" target="_blank">GNU General Public License</a>
&nbsp;for more details.
</p>
</div>
</div>
);
}
class ResetUsertokenWidget extends Component {
constructor(props) {
super(props);
this.state={
loading_status: 'done',
};
}
do_reset() {
if(window.confirm('您正在重置 UserToken!\n您的账号将会在【所有设备】上注销,您需要手动重新登录!')) {
let uid=window.prompt('您正在重置 UserToken!\n请输入您的学号以确认身份:');
if(uid)
this.setState({
loading_status: 'loading',
},()=>{
fetch(THUHOLE_API_ROOT+'api_xmcp/hole/reset_usertoken', {
method: 'post',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_token: this.props.token,
uid: uid,
}),
})
.then(get_json)
.then((json)=>{
if(json.error)
throw new Error(json.error);
else
alert('重置成功!您需要在所有设备上重新登录。');
this.setState({
loading_status: 'done',
});
})
.catch((e)=>{
alert('重置失败:'+e);
this.setState({
loading_status: 'done',
});
})
});
}
}
render() {
if(this.state.loading_status==='done')
return (<a onClick={this.do_reset.bind(this)}>重置</a>);
else if(this.state.loading_status==='loading')
return (<a><span className="icon icon-loading" /></a>);
}
}
export class LoginForm extends Component {
copy_token(token) {
if(copy(token))
alert('复制成功!\n请一定不要泄露哦');
}
render() {
return (
<TokenCtx.Consumer>{(token)=>
<div>
{/*{!!token.value &&*/}
{/* <LifeInfoBox token={token.value} set_token={token.set_value} />*/}
{/*}*/}
<div className="login-form box">
{token.value ?
<div>
<p>
<b>您已登录</b>
<button type="button" onClick={()=>{token.set_value(null);}}>
<span className="icon icon-logout" /> 注销
</button>
<br />
</p>
{/*<p>*/}
{/*根据计算中心要求,访问授权三个月内有效,过期需重新登录。*/}
{/*T大树洞将会单向加密(i.e. 哈希散列)您的邮箱后再存入数据库,因此您的发帖具有较强的匿名性。具体可见我们的<a href="https://github.com/thuhole/thuhole-go-backend/blob/76f56e6b75257b59e552b6bdba77e114151fcad1/src/db.go#L184">后端开源代码</a>。*/}
{/*</p>*/}
<p>
<a onClick={()=>{this.props.show_sidebar(
'系统消息',
<MessageViewer token={token.value} />
)}}>查看系统消息</a><br />
当您发送的内容违规时我们将用系统消息提示您
</p>
<p>
<a onClick={this.copy_token.bind(this,token.value)}>复制 User Token</a><br />
复制 User Token 可以在新设备登录切勿告知他人若怀疑被盗号请重新邮箱验证码登录以重置Token{/*,若怀疑被盗号请尽快 <ResetUsertokenWidget token={token.value} />*/}
</p>
</div> :
<LoginPopup token_callback={token.set_value}>{(do_popup)=>(
<div>
<p>
<button type="button" onClick={do_popup}>
<span className="icon icon-login" />
&nbsp;登录
</button>
</p>
<p><small>
T大树洞 面向T大学生通过T大邮箱验证您的身份并提供服务
</small></p>
</div>
)}</LoginPopup>
}
</div>
</div>
}</TokenCtx.Consumer>
)
}
}
export class ReplyForm extends Component {
constructor(props) {
super(props);
this.state={
text: '',
loading_status: 'done',
preview: false,
};
this.on_change_bound=this.on_change.bind(this);
this.area_ref=this.props.area_ref||React.createRef();
this.global_keypress_handler_bound=this.global_keypress_handler.bind(this);
this.color_picker=new ColorPicker();
}
global_keypress_handler(e) {
if(e.code==='Enter' && !e.ctrlKey && !e.altKey && ['input','textarea'].indexOf(e.target.tagName.toLowerCase())===-1) {
if(this.area_ref.current) {
e.preventDefault();
this.area_ref.current.focus();
}
}
}
componentDidMount() {
document.addEventListener('keypress',this.global_keypress_handler_bound);
}
componentWillUnmount() {
document.removeEventListener('keypress',this.global_keypress_handler_bound);
}
on_change(value) {
this.setState({
text: value,
});
}
on_submit(event) {
if(event) event.preventDefault();
if(this.state.loading_status==='loading')
return;
this.setState({
loading_status: 'loading',
});
let data=new URLSearchParams();
data.append('pid',this.props.pid);
data.append('text',this.state.text);
data.append('user_token',this.props.token);
fetch(API_BASE+'/api.php?action=docomment'+token_param(this.props.token), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: data,
})
.then(get_json)
.then((json)=>{
if(json.code!==0) {
if(json.msg) alert(json.msg);
throw new Error(JSON.stringify(json));
}
this.setState({
loading_status: 'done',
text: '',
});
this.area_ref.current.clear();
this.props.on_complete();
})
.catch((e)=>{
console.error(e);
alert('回复失败');
this.setState({
loading_status: 'done',
});
});
}
toggle_preview() {
this.setState({
preview: !this.state.preview
});
}
render() {
return (
<form onSubmit={this.on_submit.bind(this)} className={'reply-form box'+(this.state.text?' reply-sticky':'')}>
{
this.state.preview ?
<div className='reply-preview'>
<HighlightedMarkdown text={this.state.text} color_picker={this.color_picker} show_pid={()=>{}} />
</div> :
<SafeTextarea key={this.props.pid} ref={this.area_ref} id={this.props.pid} on_change={this.on_change_bound} on_submit={this.on_submit.bind(this)} />
}
<button type='button' onClick={()=>{this.toggle_preview()}}>
{this.state.preview? <span className="icon icon-eye-blocked" />: <span className="icon icon-eye" />}
</button>
{this.state.loading_status==='loading' ?
<button disabled="disabled">
<span className="icon icon-loading" />
</button> :
<button type="submit">
<span className="icon icon-send" />
</button>
}
</form>
)
}
}
export class PostForm extends Component {
constructor(props) {
super(props);
this.state={
text: '',
loading_status: 'done',
img_tip: null,
preview: false,
};
this.img_ref=React.createRef();
this.area_ref=React.createRef();
this.on_change_bound=this.on_change.bind(this);
this.on_img_change_bound=this.on_img_change.bind(this);
this.color_picker=new ColorPicker();
}
componentDidMount() {
if(this.area_ref.current)
this.area_ref.current.focus();
}
on_change(value) {
this.setState({
text: value,
});
}
do_post(text,img) {
let data=new URLSearchParams();
data.append('text',this.state.text);
data.append('type',img ? 'image' : 'text');
data.append('user_token',this.props.token);
if(img)
data.append('data',img);
fetch(API_BASE+'/api.php?action=dopost'+token_param(this.props.token), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: data,
})
.then(get_json)
.then((json)=>{
if(json.code!==0) {
if(json.msg) alert(json.msg);
throw new Error(JSON.stringify(json));
}
this.setState({
loading_status: 'done',
text: '',
});
this.area_ref.current.clear();
this.props.on_complete();
})
.catch((e)=>{
console.error(e);
alert('发表失败');
this.setState({
loading_status: 'done',
});
});
}
proc_img(file) {
return new Promise((resolve,reject)=>{
function return_url(url) {
const idx=url.indexOf(';base64,');
if(idx===-1)
throw new Error('img not base64 encoded');
return url.substr(idx+8);
}
let reader=new FileReader();
function on_got_img(url) {
const image = new Image();
image.onload=(()=>{
let width=image.width;
let height=image.height;
let compressed=false;
if(width>MAX_IMG_DIAM) {
height=height*MAX_IMG_DIAM/width;
width=MAX_IMG_DIAM;
compressed=true;
}
if(height>MAX_IMG_DIAM) {
width=width*MAX_IMG_DIAM/height;
height=MAX_IMG_DIAM;
compressed=true;
}
if(height*width>MAX_IMG_PX) {
let rate=Math.sqrt(height*width/MAX_IMG_PX);
height/=rate;
width/=rate;
compressed=true;
}
console.log('chosen img size',width,height);
let canvas=document.createElement('canvas');
let ctx=canvas.getContext('2d');
canvas.width=width;
canvas.height=height;
ctx.drawImage(image,0,0,width,height);
let quality_l=.1,quality_r=.9,quality,new_url;
while(quality_r-quality_l>=.03) {
quality=(quality_r+quality_l)/2;
new_url=canvas.toDataURL('image/jpeg',quality);
console.log(quality_l,quality_r,'trying quality',quality,'size',new_url.length);
if(new_url.length<=MAX_IMG_FILESIZE)
quality_l=quality;
else
quality_r=quality;
}
if(quality_l>=.101) {
console.log('chosen img quality',quality);
resolve({
img: return_url(new_url),
quality: quality,
width: Math.round(width),
height: Math.round(height),
compressed: compressed,
});
} else {
reject('图片过大,无法上传');
}
});
image.src=url;
}
reader.onload=(event)=>{
fixOrientation(event.target.result,{},(fixed_dataurl)=>{
on_got_img(fixed_dataurl);
});
};
reader.readAsDataURL(file);
});
}
on_img_change() {
if(this.img_ref.current && this.img_ref.current.files.length)
this.setState({
img_tip: '(正在处理图片……)'
},()=>{
this.proc_img(this.img_ref.current.files[0])
.then((d)=>{
this.setState({
img_tip: `${d.compressed?'压缩到':'尺寸'} ${d.width}*${d.height} / `+
`质量 ${Math.floor(d.quality*100)}% / ${Math.floor(d.img.length/BASE64_RATE/1000)}KB)`,
});
})
.catch((e)=>{
this.setState({
img_tip: `图片无效:${e}`,
});
});
});
else
this.setState({
img_tip: null,
});
}
on_submit(event) {
if(event) event.preventDefault();
if(this.state.loading_status==='loading')
return;
if(this.img_ref.current.files.length) {
this.setState({
loading_status: 'processing',
});
this.proc_img(this.img_ref.current.files[0])
.then((d)=>{
this.setState({
loading_status: 'loading',
});
this.do_post(this.state.text,d.img);
})
.catch((e)=>{
alert(e);
});
} else {
this.setState({
loading_status: 'loading',
});
this.do_post(this.state.text,null);
}
}
toggle_preview() {
this.setState({
preview: !this.state.preview
});
}
render() {
return (
<form onSubmit={this.on_submit.bind(this)} className="post-form box">
<div className="post-form-bar">
<label>
图片
<input ref={this.img_ref} type="file" accept="image/*" disabled={this.state.loading_status!=='done'}
onChange={this.on_img_change_bound}
/>
</label>
{
this.state.preview ?
<button type='button' onClick={()=>{this.toggle_preview()}}>
<span className="icon icon-eye-blocked" />
&nbsp;编辑
</button> :
<button type='button' onClick={()=>{this.toggle_preview()}}>
<span className="icon icon-eye" />
&nbsp;预览
</button>
}
{
this.state.loading_status!=='done' ?
<button disabled="disabled">
<span className="icon icon-loading" />
&nbsp;{this.state.loading_status==='processing' ? '处理' : '上传'}
</button> :
<button type="submit">
<span className="icon icon-send" />
&nbsp;发表
</button>
}
</div>
{!!this.state.img_tip &&
<p className="post-form-img-tip">
<a onClick={()=>{this.img_ref.current.value=""; this.on_img_change();}}>删除图片</a>
{this.state.img_tip}
</p>
}
{
this.state.preview ?
<div className='post-preview'>
<HighlightedMarkdown text={this.state.text} color_picker={this.color_picker} show_pid={()=>{}} />
</div> :
<SafeTextarea ref={this.area_ref} id="new_post" on_change={this.on_change_bound} on_submit={this.on_submit.bind(this)} />
}
<p><small>
请遵守<a href="https://thuhole.com/policy.html" target="_blank">树洞管理规范试行</a>
</small></p>
</form>
)
}
}