315 lines
8.1 KiB
TypeScript
315 lines
8.1 KiB
TypeScript
import { h, VNode } from 'snabbdom';
|
|
import { titleNameToId, bind, dataIcon, iconTag, onInsert, scrollTo } from '../util';
|
|
import { prop, Prop } from 'common';
|
|
import { makeCtrl as inviteFormCtrl } from './inviteForm';
|
|
import { StudyCtrl, StudyMember, StudyMemberMap, Tab } from './interfaces';
|
|
import { NotifCtrl } from './notif';
|
|
|
|
interface Opts {
|
|
initDict: StudyMemberMap;
|
|
myId: string | null;
|
|
ownerId: string;
|
|
send: SocketSend;
|
|
tab: Prop<Tab>;
|
|
startTour(): void;
|
|
notif: NotifCtrl;
|
|
onBecomingContributor(): void;
|
|
admin: boolean;
|
|
redraw(): void;
|
|
trans: Trans;
|
|
}
|
|
|
|
function memberActivity(onIdle: () => void) {
|
|
let timeout: Timeout;
|
|
const schedule = () => {
|
|
if (timeout) clearTimeout(timeout);
|
|
timeout = setTimeout(onIdle, 100);
|
|
};
|
|
schedule();
|
|
return schedule;
|
|
}
|
|
|
|
export function ctrl(opts: Opts) {
|
|
const dict = prop<StudyMemberMap>(opts.initDict);
|
|
const confing = prop<string | undefined>(undefined);
|
|
const active: { [id: string]: () => void } = {};
|
|
let online: { [id: string]: boolean } = {};
|
|
let spectatorIds: string[] = [];
|
|
const max = 30;
|
|
|
|
function owner() {
|
|
return dict()[opts.ownerId];
|
|
}
|
|
|
|
function isOwner() {
|
|
return opts.myId === opts.ownerId || (opts.admin && canContribute());
|
|
}
|
|
|
|
function myMember() {
|
|
return opts.myId ? dict()[opts.myId] : null;
|
|
}
|
|
|
|
function canContribute(): boolean {
|
|
const m = myMember();
|
|
return !!m && m.role === 'w';
|
|
}
|
|
|
|
const inviteForm = inviteFormCtrl(opts.send, dict, () => opts.tab('members'), opts.redraw, opts.trans);
|
|
|
|
function setActive(id: string) {
|
|
if (opts.tab() !== 'members') return;
|
|
if (active[id]) active[id]();
|
|
else
|
|
active[id] = memberActivity(() => {
|
|
delete active[id];
|
|
opts.redraw();
|
|
});
|
|
opts.redraw();
|
|
}
|
|
|
|
function updateOnline() {
|
|
online = {};
|
|
const members: StudyMemberMap = dict();
|
|
spectatorIds.forEach(function (id) {
|
|
if (members[id]) online[id] = true;
|
|
});
|
|
if (opts.tab() === 'members') opts.redraw();
|
|
}
|
|
|
|
lichess.pubsub.on('socket.in.crowd', d => {
|
|
const names: string[] = d.users || [];
|
|
inviteForm.spectators(names);
|
|
spectatorIds = names.map(titleNameToId);
|
|
updateOnline();
|
|
});
|
|
|
|
return {
|
|
dict,
|
|
confing,
|
|
myId: opts.myId,
|
|
inviteForm,
|
|
update(members: StudyMemberMap) {
|
|
if (isOwner())
|
|
confing(
|
|
Object.keys(members).find(function (sri) {
|
|
return !dict()[sri];
|
|
})
|
|
);
|
|
const wasViewer = myMember() && !canContribute();
|
|
const wasContrib = myMember() && canContribute();
|
|
dict(members);
|
|
if (wasViewer && canContribute()) {
|
|
if (lichess.once('study-tour')) opts.startTour();
|
|
opts.onBecomingContributor();
|
|
opts.notif.set({
|
|
text: opts.trans.noarg('youAreNowAContributor'),
|
|
duration: 3000,
|
|
});
|
|
} else if (wasContrib && !canContribute())
|
|
opts.notif.set({
|
|
text: opts.trans.noarg('youAreNowASpectator'),
|
|
duration: 3000,
|
|
});
|
|
updateOnline();
|
|
},
|
|
setActive,
|
|
isActive(id: string) {
|
|
return !!active[id];
|
|
},
|
|
owner,
|
|
myMember,
|
|
isOwner,
|
|
canContribute,
|
|
max,
|
|
setRole(id: string, role: string) {
|
|
setActive(id);
|
|
opts.send('setRole', {
|
|
userId: id,
|
|
role,
|
|
});
|
|
confing(undefined);
|
|
},
|
|
kick(id: string) {
|
|
opts.send('kick', id);
|
|
confing(undefined);
|
|
},
|
|
leave() {
|
|
opts.send('leave');
|
|
},
|
|
ordered() {
|
|
const d = dict();
|
|
return Object.keys(d)
|
|
.map(id => d[id])
|
|
.sort((a, b) => (a.role === 'r' && b.role === 'w' ? 1 : a.role === 'w' && b.role === 'r' ? -1 : 0));
|
|
},
|
|
size() {
|
|
return Object.keys(dict()).length;
|
|
},
|
|
isOnline(userId: string) {
|
|
return online[userId];
|
|
},
|
|
hasOnlineContributor() {
|
|
const members = dict();
|
|
for (const i in members) if (online[i] && members[i].role === 'w') return true;
|
|
return false;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function view(ctrl: StudyCtrl): VNode {
|
|
const members = ctrl.members,
|
|
isOwner = members.isOwner();
|
|
|
|
function username(member: StudyMember) {
|
|
const u = member.user;
|
|
return h(
|
|
'span.user-link.ulpt',
|
|
{
|
|
attrs: { 'data-href': '/@/' + u.name },
|
|
},
|
|
(u.title ? u.title + ' ' : '') + u.name
|
|
);
|
|
}
|
|
|
|
function statusIcon(member: StudyMember) {
|
|
const contrib = member.role === 'w';
|
|
return h(
|
|
'span.status',
|
|
{
|
|
class: {
|
|
contrib,
|
|
active: members.isActive(member.user.id),
|
|
online: members.isOnline(member.user.id),
|
|
},
|
|
attrs: { title: ctrl.trans.noarg(contrib ? 'contributor' : 'spectator') },
|
|
},
|
|
[iconTag(contrib ? 'r' : 'v')]
|
|
);
|
|
}
|
|
|
|
function configButton(ctrl: StudyCtrl, member: StudyMember) {
|
|
if (isOwner && (member.user.id !== members.myId || ctrl.data.admin))
|
|
return h('act', {
|
|
attrs: dataIcon('%'),
|
|
hook: bind(
|
|
'click',
|
|
_ => {
|
|
members.confing(members.confing() === member.user.id ? null : member.user.id);
|
|
},
|
|
ctrl.redraw
|
|
),
|
|
});
|
|
if (!isOwner && member.user.id === members.myId)
|
|
return h('act.leave', {
|
|
attrs: {
|
|
'data-icon': 'F',
|
|
title: ctrl.trans.noarg('leaveTheStudy'),
|
|
},
|
|
hook: bind('click', members.leave, ctrl.redraw),
|
|
});
|
|
return undefined;
|
|
}
|
|
|
|
function memberConfig(member: StudyMember): VNode {
|
|
const roleId = 'member-role';
|
|
return h(
|
|
'm-config',
|
|
{
|
|
key: member.user.id + '-config',
|
|
hook: onInsert(el => scrollTo($(el).parent('.members')[0] as HTMLElement, el)),
|
|
},
|
|
[
|
|
h('div.role', [
|
|
h('div.switch', [
|
|
h('input.cmn-toggle', {
|
|
attrs: {
|
|
id: roleId,
|
|
type: 'checkbox',
|
|
checked: member.role === 'w',
|
|
},
|
|
hook: bind(
|
|
'change',
|
|
e => {
|
|
members.setRole(member.user.id, (e.target as HTMLInputElement).checked ? 'w' : 'r');
|
|
},
|
|
ctrl.redraw
|
|
),
|
|
}),
|
|
h('label', { attrs: { for: roleId } }),
|
|
]),
|
|
h('label', { attrs: { for: roleId } }, ctrl.trans.noarg('contributor')),
|
|
]),
|
|
h(
|
|
'div.kick',
|
|
h(
|
|
'a.button.button-red.button-empty.text',
|
|
{
|
|
attrs: dataIcon('L'),
|
|
hook: bind('click', _ => members.kick(member.user.id), ctrl.redraw),
|
|
},
|
|
ctrl.trans.noarg('kick')
|
|
)
|
|
),
|
|
]
|
|
);
|
|
}
|
|
|
|
const ordered: StudyMember[] = members.ordered();
|
|
|
|
return h(
|
|
'div.study__members',
|
|
{
|
|
hook: onInsert(() => lichess.pubsub.emit('chat.resize')),
|
|
},
|
|
[
|
|
...ordered
|
|
.map(member => {
|
|
const confing = members.confing() === member.user.id;
|
|
return [
|
|
h(
|
|
'div',
|
|
{
|
|
key: member.user.id,
|
|
class: { editing: !!confing },
|
|
},
|
|
[h('div.left', [statusIcon(member), username(member)]), configButton(ctrl, member)]
|
|
),
|
|
confing ? memberConfig(member) : null,
|
|
];
|
|
})
|
|
.reduce((a, b) => a.concat(b), []),
|
|
isOwner && ordered.length < members.max
|
|
? h(
|
|
'div.add',
|
|
{
|
|
key: 'add',
|
|
hook: bind('click', members.inviteForm.toggle, ctrl.redraw),
|
|
},
|
|
[h('div.left', [h('span.status', iconTag('O')), h('div.user-link', ctrl.trans.noarg('addMembers'))])]
|
|
)
|
|
: null,
|
|
!members.canContribute() && ctrl.data.admin
|
|
? h(
|
|
'form.admin',
|
|
{
|
|
key: ':admin',
|
|
attrs: {
|
|
method: 'post',
|
|
action: `/study/${ctrl.data.id}/admin`,
|
|
},
|
|
},
|
|
[
|
|
h(
|
|
'button.button.button-red.button-thin',
|
|
{
|
|
attrs: { type: 'submit' },
|
|
},
|
|
'Enter as admin'
|
|
),
|
|
]
|
|
)
|
|
: null,
|
|
]
|
|
);
|
|
}
|