= { doAsOpts: {
+ doAsAnon,
+ myAliasOpts: state.doAsOpts.myAliasOpts
+ }};
+ this.setState(newState);
+ } });
+ } },
+ persona.whichAnon_titleShort(state.doAsOpts.doAsAnon, { me }),
+ ' ', r.span({ className: 'caret' })));
+ }
+
let layoutAndSettings: RElm | U;
if (simpleChanges) {
const layoutBtnTitle = r.span({},
@@ -368,7 +399,8 @@ export const TitleEditor = createComponent({
return (
r.div({ className: 'dw-p-ttl-e' },
- Input({ type: 'text', ref: 'titleInput', className: 'dw-i-title', id: 'e2eTitleInput',
+ maybeAnonymously,
+ Input({ type: 'text', ref: 'titleInput', className: 'c_TtlE_TtlI',
defaultValue: titlePost.unsafeSource, onChange: this.onTitleChanged }),
r.div({ className: 'form-horizontal' }, selectCategoryInput),
r.div({ className: 'form-horizontal' }, selectTopicType),
diff --git a/client/app-more/editor/title-editor.styl b/client/app-more/editor/title-editor.styl
index 7afe95cbdd..a4933af637 100644
--- a/client/app-more/editor/title-editor.styl
+++ b/client/app-more/editor/title-editor.styl
@@ -1,4 +1,7 @@
+.c_AliasB + div .c_TtlE_TtlI
+ margin-top: 10px;
+
.esTtlEdtr_urlSettings
background: hsl(0, 0%, 99%);
border: 1px solid hsl(0, 0%, 92%);
diff --git a/client/app-more/help/help-dialog.more.ts b/client/app-more/help/help-dialog.more.ts
index 6d2bbb475e..1381633098 100644
--- a/client/app-more/help/help-dialog.more.ts
+++ b/client/app-more/help/help-dialog.more.ts
@@ -37,6 +37,8 @@ let helpDialog;
function getHelpDialog() {
if (!helpDialog) {
+ // Apparently, if you're somewhere in a React componentDidUpdate() handler, then,
+ // `helpDialog` becomes null. Then you can wrap getHelpDialog() in setTimeout().
helpDialog = ReactDOM.render(HelpDialog(), utils.makeMountNode());
}
return helpDialog;
@@ -86,6 +88,9 @@ const HelpDialog = createComponent({
onChange: (event) => this.setState({ hideNextTime: event.target.checked }),
label: "Hide this tips" });
+ const maybeClose =
+ !message || message.closeOnClickOutside === false ? undefined : this.close;
+
content = !content ? null :
ModalBody({ className: message.className },
r.div({ className: 'esHelpDlg_body_wrap'},
@@ -95,7 +100,7 @@ const HelpDialog = createComponent({
PrimaryButton({ onClick: this.close, className: 'e_HelpOk' }, "Okay"))));
return (
- Modal({ show: this.state.isOpen, onHide: this.close, dialogClassName: 'esHelpDlg' },
+ Modal({ show: this.state.isOpen, onHide: maybeClose, dialogClassName: 'esHelpDlg' },
content));
}
});
diff --git a/client/app-more/login/login-dialog.more.ts b/client/app-more/login/login-dialog.more.ts
index df1ef6f704..3c49c48a53 100644
--- a/client/app-more/login/login-dialog.more.ts
+++ b/client/app-more/login/login-dialog.more.ts
@@ -199,6 +199,7 @@ const LoginDialog = createClassAndFactory({
getSetCookie('dwCoMayCreateUser', null);
getSetCookie('dwCoOAuth2State', null);
getSetCookie('esCoImp', null);
+ //getSetCookie('TyCoPersona', null);
if (!eds.isInLoginWindow) {
// We're in a login popup, not in a dedicated "full screen" login window.
diff --git a/client/app-more/more-bundle-already-loaded.d.ts b/client/app-more/more-bundle-already-loaded.d.ts
index 8e2b0df73a..5978a9f0fd 100644
--- a/client/app-more/more-bundle-already-loaded.d.ts
+++ b/client/app-more/more-bundle-already-loaded.d.ts
@@ -32,6 +32,7 @@ declare namespace debiki2 {
namespace morekit {
function openProxyDiag(ps: ProxyDiagParams, childrenFn: (close: () => V) => RElm);
+ function openSimpleProxyDiag(ps: ProxyDiagParams & { body: RElm });
}
var Expandable;
@@ -73,7 +74,7 @@ declare namespace debiki2.pagedialogs {
function openAddPeopleDialog(ps: { curPatIds?: PatId[], curPats?: Pat[],
mayClear?: Bo, onChanges: (PatsToAddRemove) => Vo });
- function openDeletePostDialog(post: Post, at: Rect);
+ function openDeletePostDialog(ps: { post: Post, at: Rect, doAsAnon?: MaybeAnon });
function openFlagDialog(postId: PostId, at: Rect);
function openMovePostsDialog(store: Store, post: Post, closeCaller, at: Rect);
function openSeeWrenchDialog();
@@ -87,18 +88,17 @@ declare namespace debiki2.pagedialogs {
function getProgressBarDialog();
}
-declare namespace debiki2.anon {
- function maybeChooseModAlias(ps: MaybeChooseAnonPs, then?: (res: ChoosenAnon) => V);
- function maybeChooseAnon(ps: MaybeChooseAnonPs, then?: (_: ChoosenAnon) => V): ChoosenAnon;
- function openAnonDropdown(ps: ChooseAnonDlgPs): V;
+declare namespace debiki2.persona {
+ function chooseEditorPersona(ps: ChooseEditorPersonaPs, then?: (_: DoAsAndOpts) => V): V;
+ function choosePosterPersona(ps: ChoosePosterPersonaPs, then?: (_: DoAsAndOpts | 'CANCEL') => V)
+ : DoAsAndOpts;
+ function openAnonDropdown(ps: ChoosePersonaDlgPs): V;
function whichAnon_titleShort(doAsAnon: MaybeAnon, ps: { me: Me, pat?: Pat }): RElm;
function whichAnon_title(doAsAnon: MaybeAnon, ps: { me: Me, pat?: Pat }): St | RElm;
function whichAnon_descr(doAsAnon: MaybeAnon, ps: { me: Me, pat?: Pat }): St | RElm;
-}
-declare namespace debiki2 {
- function disc_findAnonsToReuse(discStore: DiscStore, ps: {
- forWho: Pat | Me | U, startAtPostNr?: PostNr }): MyPatsOnPage;
+ function openPersonaInfoDiag(ps: { atRect: Rect, isSectionPage: Bo,
+ me: Me, personaOpts: PersonaOptions, discProps: DiscPropsDerived }): V;
}
declare namespace debiki2.subcommunities {
diff --git a/client/app-more/morekit/proxy-diag.more.ts b/client/app-more/morekit/proxy-diag.more.ts
index 804edc2f3b..21acf21e97 100644
--- a/client/app-more/morekit/proxy-diag.more.ts
+++ b/client/app-more/morekit/proxy-diag.more.ts
@@ -35,6 +35,37 @@ interface ProxyDiagState {
let setDropdownStateFromOutside: U | ((_: ProxyDiagState | N) => V);
+// Much later, support all StupidDialogStuff, and [replace_stupid_diag_w_simple_proxy_diag].
+//
+export function openSimpleProxyDiag(ps: SimpleProxyDiagParams) {
+ openProxyDiag({ ...ps, flavor: DiagFlavor.Dropdown }, closeDiag => {
+
+ const primaryButton = PrimaryButton({ className: 'e_SPD_OkB',
+ ref: (e: HElm | N) => e && e.focus(),
+ onClick: () => {
+ closeDiag();
+ if (ps.onPrimaryClick) ps.onPrimaryClick();
+ if (ps.onCloseOk) ps.onCloseOk(1);
+ }},
+ ps.primaryButtonTitle || t.Okay);
+
+ const secondaryButton = !ps.secondaryButonTitle ? null : Button({
+ onClick: () => {
+ closeDiag();
+ if (ps.onCloseOk) ps.onCloseOk(2);
+ },
+ className: 'e_SPD_2ndB' },
+ ps.secondaryButonTitle);
+
+ return rFr({},
+ r.div({ style: { marginBottom: '2em' }}, ps.body),
+ r.div({ style: { float: 'right' }},
+ primaryButton,
+ secondaryButton));
+ });
+}
+
+
export function openProxyDiag(params: ProxyDiagParams, childrenFn: (close: () => V) => RElm) {
if (!setDropdownStateFromOutside) {
ReactDOM.render(ProxyDiag(), utils.makeMountNode());
@@ -61,8 +92,11 @@ const ProxyDiag = React.createFactory<{}>(function() {
const state: ProxyDiagState = diagState;
const ps: ProxyDiagParams = state.params;
- const close = () => setDiagState(null);
const flavorClass = ps.flavor === DiagFlavor.Dropdown ? 'c_PrxyD-Drpd ' : '';
+ const close = () => {
+ setDiagState(null);
+ if (diagState.params.onHide) diagState.params.onHide();
+ };
return utils.DropdownModal({
show: true,
@@ -74,6 +108,7 @@ const ProxyDiag = React.createFactory<{}>(function() {
className: ps.contentClassName,
allowFullWidth: ps.allowFullWidth,
showCloseButton: ps.showCloseButton !== false,
+ closeOnClickOutside: ps.closeOnClickOutside,
// bottomCloseButton: not yet impl
onContentClick: !ps.closeOnButtonClick ? null : (event: MouseEvent) => {
// Don't close if e.g. clicking a , maybe to select text — only if
diff --git a/client/app-more/morekit/proxy-diag.styl b/client/app-more/morekit/proxy-diag.styl
index 4ac296b491..b87f6740cc 100644
--- a/client/app-more/morekit/proxy-diag.styl
+++ b/client/app-more/morekit/proxy-diag.styl
@@ -4,6 +4,8 @@
.c_PrxyD
.esDropModal_content
padding: 30px 20px 20px 20px;
+ .esDropModal_header
+ margin: 7px 0 7px 11px; // less margin-top – already 30px padding-top, see above
.c_PrxyD-Drpd
// The dropdown items have their own padding, so, could use less padding-left,
diff --git a/client/app-more/oop.more.ts b/client/app-more/oop.more.ts
deleted file mode 100644
index cd6a2b7e41..0000000000
--- a/client/app-more/oop.more.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-/*
- * Copyright (c) 2023 Kaj Magnus Lindberg
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * 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
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-///
-
-//------------------------------------------------------------------------------
- namespace debiki2 {
-//------------------------------------------------------------------------------
-
-
-/// disc_findAnonsToReuse()
-///
-/// If pat is posting a reply anonymously, then, if han has posted or voted earlier
-/// anonymously on the same page, usually han wants hens new reply, to be
-/// by the same anonym, so others see they're talking with the same person
-/// (although they don't know who it is, just that it's the same).
-///
-/// This fn finds anonyms a pat has used, so the pat can reuse them. First it
-/// looks for anonyms-to-reuse in the sub thread where pats reply will appear,
-/// thereafter anywhere on the same page.
-///
-/// Returns sth like this, where 200 is pat's id, and 2001, 2002, 2003, 2004 are
-/// anons pat has used on the current page: (just nice looking numbers)
-/// {
-/// // Just an example
-/// byId: {
-/// 2001: patsAnon2001, // = { id: 2001, forPatId: 200, ... }
-/// 2002: patsAnon2002, // = { id: 2002, forPatId: 200, ... }
-/// 2003: patsAnon2003, // = { id: 2003, forPatId: 200, ... }
-/// 2004: patsAnon2004, // = { id: 2004, forPatId: 200, ... }
-/// },
-/// sameThread: [
-/// patsAnon2002, // pat's anon (or pat henself) who made pat's last comment
-// // in the path from startAtPostNr, back to the orig post.
-/// patHenself, // here pat posted using hens real account (not anonymously)
-/// patsAnon2001, // pat upvoted a comment using anon 2001, along this path
-/// ],
-/// outsideThread: [
-/// // If pat has posted earlier in the thread (closer to the orig post), using
-/// // any of the above (anon 2002, 2001, or as henself), those comments are
-/// // ignored: we don't add an anon more than once to the list.)
-///
-/// patsAnon2004, // Pat replied elsewhere on the page using hens anon 2004
-/// patsAnon2003, // ... and before that, han posted as anon 2003, also
-/// // elsewhere on the same page.
-/// ]
-/// }
-///
-/// In the above example, patsAnon2003 didn't post anything in the thread from
-/// startAtPostNr up to the orig post — but that anon did post something,
-/// *elsewhere* in the same discussion. So that anon is still in the list of anons
-/// pat might want to use again, on this page.
-///
-export function disc_findAnonsToReuse(discStore: DiscStore, ps: {
- forWho: Pat | Me | U, startAtPostNr?: PostNr }): MyPatsOnPage {
-
- const result: MyPatsOnPage = {
- sameThread: [],
- outsideThread: [],
- byId: {},
- };
-
- const forWho: Pat | Me | U = ps.forWho;
- if (!forWho)
- return result;
-
- const forWhoId: PatId = ps.forWho.id;
- const curPage: Page | U = discStore.currentPage;
-
- if (!forWhoId || !curPage)
- return result;
-
- // ----- Same thread
-
- // Find out if pat was henself, or was anonymous, in any earlier posts by hen,
- // in the path from ps.startAtPostNr and back towards the orig post.
- // (patsAnon2002, patHenself, and patsAnon2001 in the example above (i.e. in
- // the docs comment to this fn)).
-
- const startAtPost: Post | U = ps.startAtPostNr && curPage.postsByNr[ps.startAtPostNr];
- const nrsSeen = {};
- let myVotesByPostNr: { [postNr: PostNr]: Vote[] } = {};
-
- const isMe = pat_isMe(forWho);
- if (isMe) {
- myVotesByPostNr = forWho.myDataByPageId[curPage.pageId]?.votesByPostNr || {};
- }
- else {
- die('TyE0MYVOTS'); // [_must_be_me]
- }
-
- let nextPost: Post | U = startAtPost;
- const myAliasesInThread = [];
-
- for (let i = 0; i < StructsAndAlgs.TooLongPath && nextPost; ++i) {
- // Cycle? (Would be a bug somewhere.)
- if (nrsSeen[nextPost.nr])
- break;
- nrsSeen[nextPost.nr] = true;
-
- // Bit dupl code: [.find_anons]
-
- // We might have added this author, already.
- if (result.byId[nextPost.authorId])
- continue;
-
- const author: Pat | U = discStore.usersByIdBrief[nextPost.authorId];
- if (!author)
- continue; // would be a bug somewhere, or a rare & harmless race? Oh well.
-
- const postedAsSelf = author.id === forWhoId;
- const postedAnonymously = author.anonForId === forWhoId;
-
- if (postedAsSelf || postedAnonymously) {
- // This places pat's most recently used anons first.
- myAliasesInThread.push(author);
- result.byId[author.id] = author;
- }
- else {
- // This comment is by someone else. If we've voted anonymously, let's
- // continue using the same anonym. Or using our main user account, if we've
- // voted not-anonymously.
- const votes: Vote[] = myVotesByPostNr[nextPost.nr] || [];
- for (const myVote of votes) {
- // If myVote.byId is absent, it's our own vote (it's not anonymous). [_must_be_me]
- const voterId = myVote.byId || forWho.id;
- // Have we added this alias (or our real account) already?
- if (result.byId[voterId])
- continue;
- const voter: Pat = discStore.usersByIdBrief[voterId];
- myAliasesInThread.push(voter);
- result.byId[voter.id] = voter;
- }
- }
-
- nextPost = curPage.postsByNr[nextPost.parentNr];
- }
-
- // ----- Same page
-
- // If pat posted outside [the thread from the orig post to ps.startAtPostNr],
- // then include any anons pat used, so Pat can choose to use those anons, now
- // when being active in sub thread startAtPostNr. (See patsAnon2003 and patsAnon2004
- // in this fn's docs above.)
-
- // Sleeping BUG:, ANON_UNIMPL: What if it's a really big page, and we don't have
- // all parts here, client side? Maybe this ought to be done server side instead?
- // Or the server could incl all one's anons on the current page, in a list [fetch_alias]
-
- const myAliasesOutsideThread: Pat[] = [];
-
- _.forEach(curPage.postsByNr, function(post: Post) {
- if (nrsSeen[post.nr])
- return;
-
- // Bit dupl code: [.find_anons]
-
- // Each anon pat has used, is to be included at most once.
- if (result.byId[post.authorId])
- return;
-
- const author: Pat | U = discStore.usersByIdBrief[post.authorId];
- if (!author)
- return;
-
- const postedAsSelf = author.id === forWhoId;
- const postedAnonymously = author.anonForId === forWhoId;
-
- if (postedAsSelf || postedAnonymously) {
- myAliasesOutsideThread.push(author);
- result.byId[author.id] = author;
- }
- });
-
- _.forEach(myVotesByPostNr, function(votes: Vote[], postNrSt: St) {
- if (nrsSeen[postNrSt])
- return;
-
- for (const myVote of votes) {
- // The voter is oneself or one's anon or pseudonym. [_must_be_me]
- const voterId = myVote.byId || forWho.id;
-
- if (result.byId[voterId])
- return;
-
- const voter: Pat | U = discStore.usersByIdBrief[voterId]; // [voter_needed]
- if (!voter)
- return;
-
- myAliasesOutsideThread.push(voter);
- result.byId[voter.id] = voter;
- }
- });
-
-
- // Sort, newest first. Could sort votes by voted-at, not the comment posted-at — but
- // doesn't currently matter, not until [many_anons_per_page].
- // Old — now both comments and votes, so won't work:
- //myPostsOutsideThread.sort((p: Post) => -p.createdAtMs);
- //const myPatsOutside = myPostsOutsideThread.map(p => discStore.usersByIdBrief[p.authorId]);
-
- // ----- The results
-
- result.sameThread = myAliasesInThread;
- result.outsideThread = myAliasesOutsideThread;
-
- return result;
-}
-
-
-
-//------------------------------------------------------------------------------
- }
-//------------------------------------------------------------------------------
-// vim: fdm=marker et ts=2 sw=2 tw=0 fo=r list
diff --git a/client/app-more/page-dialogs/ChangePageModal.more.ts b/client/app-more/page-dialogs/ChangePageModal.more.ts
index a36bab4351..5b0a0d5dae 100644
--- a/client/app-more/page-dialogs/ChangePageModal.more.ts
+++ b/client/app-more/page-dialogs/ChangePageModal.more.ts
@@ -112,15 +112,19 @@ const ChangePageDialog = createComponent({
const savePage = (changes: EditPageRequestData) => {
// E.g. EditPageRequestData.showId and .htmlTagCssClasses can be edited by admins only,
// and should be grayed out if one is in Anon Mode for example? [alias_mode]
- anon.maybeChooseModAlias({ store, atRect: state.atRect }, (choices: ChoosenAnon) => {
+ // (A [pick_persona_click_handler] could save a few lines of code?)
+ persona.chooseEditorPersona({ store, atRect: state.atRect, isInstantAction: true },
+ (choices: DoAsAndOpts) => {
ReactActions.editTitleAndSettings({ ...changes, doAsAnon: choices.doAsAnon },
this.close, null);
});
}
const togglePageClosed = () => {
- anon.maybeChooseModAlias({ store, atRect: state.atRect }, (choices: ChoosenAnon) => {
- ReactActions.togglePageClosed(choices.doAsAnon, this.close);
+ persona.chooseEditorPersona({ store, atRect: state.atRect, isInstantAction: true },
+ (choices: DoAsAndOpts) => {
+ const pageId = Server.getPageId();
+ ReactActions.togglePageClosed({ pageId, doAsAnon: choices.doAsAnon }, this.close);
});
};
@@ -294,23 +298,14 @@ const ChangePageDialog = createComponent({
deletePageListItem = rFr({},
r.div({ className: 's_ExplDrp_Ttl' }, "Delete page" + '?'), // I18N
r.div({ className: 's_ExplDrp_ActIt' },
- Button({ className: 'e_DelPgB',
- onClick: () => {
- // ANON_UNIMPL Do as (mod) alias?
- ReactActions.deletePages([page.pageId], this.close);
- } },
- "Delete"))); // I18N
+ DeletePageBtn({ pageIds: [page.pageId], store, close: this.close })));
}
else if (store_canUndeletePage(store)) { // [.store_or_state_pg]
undeletePageListItem = rFr({},
r.div({ className: 's_ExplDrp_Ttl' }, "Undelete page" + '?'), // I18N
r.div({ className: 's_ExplDrp_ActIt' },
- Button({ className: 'e_UndelPgB',
- onClick: () => {
- // ANON_UNIMPL Do as (mod) alias?
- ReactActions.undeletePages([page.pageId], this.close);
- } },
- "Undelete"))); // I18N
+ DeletePageBtn({ pageIds: [store.currentPageId], store,
+ undel: true, close: this.close })));
}
}
diff --git a/client/app-more/page-dialogs/about-user-dialog.more.ts b/client/app-more/page-dialogs/about-user-dialog.more.ts
index 2a29a021f4..bd89400379 100644
--- a/client/app-more/page-dialogs/about-user-dialog.more.ts
+++ b/client/app-more/page-dialogs/about-user-dialog.more.ts
@@ -333,7 +333,7 @@ const AboutAnon = React.createFactory(function(props) {
r.div({ className: 'dw-about-user-actions' },
LinkButton({ href: linkToUserProfilePage(anon) }, t.aud.ViewComments)),
r.p({},
- t.Anonym)));
+ anonStatus_toStr(anon.anonStatus))));
});
diff --git a/client/app-more/page-dialogs/choose-author-owner.more.ts b/client/app-more/page-dialogs/choose-author-owner.more.ts
index c76ab0098f..da4ab0e504 100644
--- a/client/app-more/page-dialogs/choose-author-owner.more.ts
+++ b/client/app-more/page-dialogs/choose-author-owner.more.ts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 Kaj Magnus Lindberg
+ * Copyright (c) 2024 Kaj Magnus Lindberg
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -16,9 +16,10 @@
*/
///
+///
//------------------------------------------------------------------------------
- namespace debiki2.anon {
+ namespace debiki2.persona {
//------------------------------------------------------------------------------
const r = ReactDOMFactories;
@@ -26,160 +27,379 @@ const DropdownModal = utils.DropdownModal;
const ExplainingListItem = util.ExplainingListItem;
-export function maybeChooseModAlias(ps: MaybeChooseAnonPs, then?: (res: ChoosenAnon) => V) {
+/// Figures out, or asks, what persona to use when editing a post. [choose_persona]
+///
+/// Ensures pat will continue using the same persona, when editing a post, as when
+/// creating it. E.g. if posting a question, and later on, accepting an answer.
+///
+/// If pat were to use a different persona when editing a post of hans,
+/// others might guess that the two personas (pat, and hans alias)
+/// are in fact the same (sine they were able to edit the same thing). [deanon_risk]
+/// (E.g. pat creates a page anonymously, and then edits it as hanself,
+/// without being a moderator. This function makes sure han will instead
+/// reuse the anonym, when editing the page.)
+///
+/// If a moderator edits someone else's page, then, if han uses a persona
+/// (e.g. a pseudonym), others might guess that that persona is a moderator
+/// (since that persona was able to edit other's posts).
+/// And that's no good. So, for now, one can't use an alias when altering
+/// someone else's page — one has to do that as oneself. [deanon_risk]
+/// Later on, if a moderator really wants to alter sbd elses page using
+/// an alias, maybe that should by default be a different alias, e.g.
+/// a new anonym, than what they use if they're part of the discussion
+/// on the page and trying to be anonymous. [anon_mods]
+///
+/// If trying to do sth not-allowed, shows an error dialog and won't call `then`.
+///
+export function chooseEditorPersona(ps: ChooseEditorPersonaPs, then?: (res: DoAsAndOpts) => V) {
+
+ // TESTS_MISSING TyTALIALTERPG
+
const page: Page = ps.store.currentPage;
dieIf(!page, 'TyE56032MWJ');
const post = page.postsByNr[ps.postNr || BodyNr];
const author = ps.store.usersByIdBrief[post.authorId];
const me: Me = ps.store.me;
+ const dp: DiscPropsDerived | U = ps.store.curDiscProps;
+
+ const pseudonymsAllowed = false; // [pseudonyms_later]
+ const anonsAllowed = dp && dp.comtsStartAnon >= NeverAlways.Allowed;
+ const mustBeAnon = dp && dp.comtsStartAnon >= NeverAlways.AlwaysButCanContinue;
+
+ const switchedToPseudonym = false; // [pseudonyms_later]
+ const switchedToAnonym = me.usePersona && !!me.usePersona.anonStatus;
+ const switchedToSelf = me.usePersona && me.usePersona.self;
+
const postedAsSelf = author.id === me.id;
const postedAsAlias = author.anonForId === me.id;
- // ANON_UNIMPL Might want to do anonymously, or using a pseudonym, regardless
- // of history. [alias_mode]
if (!postedAsSelf && !postedAsAlias) {
// Ask the server for any existing anon, or create a new one?
- // But for now, continue as oneself (don't use an alias) — only works if is
- // mod or admin, otherwise the server will return a permission error. [anon_mods]
- then({ doAsAnon: false, myAliasOpts: [false] });
+ // For now, continue as oneself (don't use an alias) — only works if is
+ // pat has `mayEditPage` or `mayEditComment` or `mayEditWiki` permissions,
+ // otherwise the server will return a permission error. Later, could create
+ // a new anon for any moderator actions (so others can't know that some anonymous
+ // comments in the discussion, are by a moderator). [anon_mods]
+
+ if (ps.draft) {
+ if (!ps.draft.doAsAnon) { // [oneself_0_false]
+ // Pat has already started editing as hanself — don't pop up any dialog again.
+ editAsSelf();
+ return;
+ }
+ else {
+ // Pat started editing using an alias, but that's not supported / allowed.
+ // Continue below: Show the info message, and edit as hanself.
+ }
+ }
+
+ // COULD allow if is wiki [alias_ed_wiki] [alias_0_ed_others]
+ const errMsg: St | N = !mustBeAnon ? null :
+ "You cannot edit other people's posts here. This is an anonymous-" +
+ "only secttion, but you cannot edit others' posts anonymously, as of now.";
+ if (errMsg) {
+ morekit.openSimpleProxyDiag({ atRect: ps.atRect, body: rFr({}, errMsg) });
+ return;
+ }
+
+ const infoMsg: St | N =
+ switchedToAnonym ? "You cannot edit other people's posts anonymously" : (
+ switchedToPseudonym ? "You cannot edit other people's posts under " +
+ "a pseudonymous name" : (
+ // For clarity, if it's possible to be anonymous, then require that pat has
+ // explicitly chooses to post as hanself.
+ // UX COULD [alias_ux] If pat has commented or done sth on/with the
+ // page as hanself, then, don't ask, just continue as pat hanself.
+ // Look in `store.curPersonaOptions.optsList[0]` — if it's pat hanself
+ // (not an alias), and it's also the best guess, the that's enough
+ // (set infoMsg to null).
+ (anonsAllowed || pseudonymsAllowed) && !switchedToSelf ?
+ "You cannot edit other people's posts anonymously" : (
+ // Need not show any message (continue with `editAsSelf()` below).
+ null)));
+ if (infoMsg) {
+ // If it's a one-click thing, like accepting an answer, "Edit post as" is confusing?
+ // There's no editor involved. "Do as: ..." is better, right.
+ const editAs = ps.isInstantAction ? "Do as" : "Edit post as"; // I18N
+ const body = rFr({},
+ r.p({}, `${editAs} yourself, ${pat_name(me)}?`), // I18N
+ infoMsg);
+ morekit.openSimpleProxyDiag({ atRect: ps.atRect, body,
+ primaryButtonTitle: "Yes, do as me", // I18N
+ secondaryButonTitle: t.Cancel,
+ onPrimaryClick: () => {
+ editAsSelf();
+ }});
+ return;
+ }
+
+ function editAsSelf() {
+ // _Only_one_item (`false`) in the list — must edit as oneself.
+ then({ doAsAnon: false, myAliasOpts: [false] }); // [oneself_0_false]
+ }
+
+ editAsSelf();
}
else {
- // [deanon_risk] Might not want to do mod-only things using an anonym that has
- // also posted comments (so won't show that that anon is a mod).
- const anyAlias = postedAsSelf
- ? false
- : { sameAnonId: author.id, anonStatus: author.anonStatus } as SameAnon;
-
- then({ doAsAnon: anyAlias, myAliasOpts: [anyAlias] })
+ // Continue using the same persona, as when creating the page. (See the descr
+ // of this fn.)
+ const anyAlias: MaybeAnon = postedAsSelf
+ ? false // [oneself_0_false]
+ : { sameAnonId: author.id, anonStatus: author.anonStatus } satisfies SameAnon;
+
+ const modeAuthorMismatch =
+ postedAsSelf && switchedToAnonym || postedAsAlias && switchedToSelf;
+
+ // If draft author != post author, pat needs to continue editing as the *post author*.
+ // (If using the draft author persona, others might guess that that one, and the
+ // post author, are the same. For example, if you posted as Anonym A (post author),
+ // then somehow managed to save a draft with yourself as author, then, when you resume
+ // the draft, you'll be editing as Anon A again, so others can't see that both
+ // you and Anon A can edit the same post.) [true_0_ed_alias] [alias_0_ed_others]
+ if (ps.draft && isVal(ps.draft.doAsAnon) && ps.draft.doAsAnon !== anyAlias // [oneself_0_false]
+ || modeAuthorMismatch) {
+ // TESTS_MISSING TyTDRAFTALI
+ // [pseudonyms_later]
+ const edit = ps.isInstantAction ? "do" : "edit";
+ const asWho = // I18N and below
+ anyAlias === false ? // [oneself_0_false]
+ rFr({}, r.b({}, "yourself"), " (not anonymously)") : (
+ anyAlias.anonStatus ?
+ r.b({}, anonStatus_toStr(anyAlias.anonStatus, Verbosity.Full)) :
+ "unknown [TyEUNKALI]"); // D_DIE
+ // [close_cross_css]
+ const body = r.p({ style: { marginRight: '30px' }},
+ `You will ${edit} as `, asWho);
+ const asMeOrAnon = anyAlias === false ? "as me" : "anonymously";
+
+ morekit.openSimpleProxyDiag({ atRect: ps.atRect, body,
+ primaryButtonTitle: `Ok, I'll ${edit} ${asMeOrAnon}`,
+ secondaryButonTitle: t.Cancel,
+ onPrimaryClick: () => {
+ then({ doAsAnon: anyAlias, myAliasOpts: [anyAlias] });
+ }});
+ }
+ else {
+ // _Only_one_item (`anyAlias`) in the list.
+ then({ doAsAnon: anyAlias, myAliasOpts: [anyAlias] });
+ }
}
}
-/// Figures out which alias to use (if any), when replying, voting, editing one's
-/// page, etc. Uses the same anon as most recently in the same sub thread, if any,
-/// otherwise the same as elsewhere on the page. ANON_UNIMPL: But if a moderator does sth
-/// only mods may do, creates a new anon for moderator actions (to not show that
-/// any anon used for comments, is a moderator). [anon_mods]
-/// [alias_mode] If one is in e.g. Anon Mode, does things anonymously if allowed,
-/// or pops up a dialog and says it's not allowed here (e.g. this category).
+/// Figures out, or pops up a dialog and asks, which alias to use (if any), when
+/// posting comments or pages, or voting ("posting" a vote). [choose_persona]
///
-/// Later: if `then` specified, might ask the server to suggest which alias to
-/// use and/or pop up a dialog. Otherwise, suggests something directly
-/// (which can be good, if opening the editor — there's an alias dropdown there,
-/// so can change later, no need for another dialog).
+/// Prefers the same alias as most recently in the same sub thread, if any,
+/// Then the same as elsewhere on the page. (See findPersonaOptions().)
+/// Then any persona mode alias [alias_mode], then any per category default persona,
+/// e.g. anonymous in anon-by-default cats.
///
-export function maybeChooseAnon(ps: MaybeChooseAnonPs, then?: (_: ChoosenAnon) => V)
- : ChoosenAnon {
-
- const discStore: DiscStore = ps.store;
- const postNr: PostNr | U = ps.postNr;
- const discProps: DiscPropsDerived = ps.discProps || page_deriveLayout(
- discStore.currentPage, discStore, LayoutFor.PageNoTweaks);
+/// Later: Might ask the server to suggest which alias to use [fetch_alias].
+///
+export function choosePosterPersona(ps: ChoosePosterPersonaPs,
+ then?: (_: DoAsAndOpts | 'CANCEL') => V)
+ : DoAsAndOpts {
- const myAliasesHere: MyPatsOnPage | U = postNr && disc_findAnonsToReuse(discStore, {
- forWho: discStore.me, startAtPostNr: postNr });
+ // @ifdef DEBUG
+ dieIf(then && !ps.atRect, "Wouldn't know where to open dialog [TyE70WJE35]");
+ // @endif
- const anonsAllowed = discProps.comtsStartAnon >= NeverAlways.Allowed;
- const anonsRecommended = discProps.comtsStartAnon >= NeverAlways.Recommended;
+ // ----- Derive persona options list
- // When someone starts posting on the relevant page, must they be anonymous?
- const mustBeAnon = discProps.comtsStartAnon >= NeverAlways.AlwaysButCanContinue;
+ // (We can't use `DiscStore.curPersonaOptions`: We want persona options for the
+ // comments thread ending at `ps.postNr`, but `curPersonaOptions` is for the whole
+ // page, no specific sub thread.)
- // It is ok to *continue* posting using one's real account, on
- // this page, if comments-start-anon is Always-**But-Can-Continue** posting
- // using one's real name.
- const canContinueAsSelf =
- discProps.comtsStartAnon <= NeverAlways.AlwaysButCanContinue;
+ const myPersonasThisPage = disc_findMyPersonas(
+ ps.discStore, { forWho: ps.me, startAtPostNr: ps.postNr });
+ const personaOpts: PersonaOptions = findPersonaOptions({
+ myPersonasThisPage, me: ps.me, discProps: ps.discStore.curDiscProps });
- // @ifdef DEBUG
- dieIf(mustBeAnon, "Unimpl: mustBeAnon [TyE602MKG1]");
- dieIf(!canContinueAsSelf, "Unimpl: !canContinueAsSelf [TyE602MKG2]");
- dieIf(anonsAllowed && discProps.newAnonStatus === AnonStatus.NotAnon, "TyE6@NJ04");
- // @endif
+ const res: DoAsAndOpts = {
+ doAsAnon: personaOpts ? personaOpts.optsList[0].doAs : false, // [oneself_0_false]
+ myAliasOpts: personaOpts ? personaOpts.optsList.map(a => a.doAs) : [false],
+ }
- if (myAliasesHere &&
- !myAliasesHere.sameThread.length &&
- myAliasesHere.outsideThread.length >= 2) {
- // UX: ask which alias to use? [choose_alias] unless it's clear from any
- // current [alias_mode] which one to use. ANON_UNIMPL
- // Pat has commented or voted using two or more different aliases, or hans real
- // account and an alias, on this page, but not in this thread. — Then, hard to know
- // which alias han wants to continue using.
- // If asking, and pat wants to use hans real name, maybe show an extra
- // "Use your real name? (That is, @your_username) [yes / no]" dialog?
- //
- // For now, default to doing things anonymously if pat has been anon
- // anywhere on this page.
- // So: noop() here, for now.
+ if (ps.draft && isVal(ps.draft.doAsAnon)) {
+ addDraftAuthor_inPl(ps.draft, res);
+ personaOpts.isAmbiguous = false; // will use the author of the draft
}
- // The user accounts the current has used on this page — first looking higher up in
- // the same sub thread, then looking at the whole page.
- const patsByThreadThenLatest = !myAliasesHere ? [] :
- [...myAliasesHere.sameThread, ...myAliasesHere.outsideThread];
-
- // Only looking at the same sub thread (parent comment, grandparent etc).
- const lastPatSameThread: Pat | U = myAliasesHere?.sameThread[0];
-
- const lastAnonPat: Pat | U = patsByThreadThenLatest.find(p => p.isAnon);
- const lastAnon: WhichAnon | N = !lastAnonPat ? null :
- { sameAnonId: lastAnonPat.id, anonStatus: lastAnonPat.anonStatus } as SameAnon;
-
- const doAsAnon: WhichAnon | false = !patsByThreadThenLatest.length
- ? (
- // We haven't posted or voted on this page before.
- // Create a new anonym, iff recommended.
- anonsRecommended
- ? { newAnonStatus: discProps.newAnonStatus } as NewAnon
- : false // don't do as anon, use real account
- )
- : (
- // We have posted on this page before. But in the same sub thread?
- lastPatSameThread
- ? (
- // Continue using our earlier account, from the same sub thread.
- // (Either an anonym, or ourself.)
- lastPatSameThread.isAnon ? lastAnon : false)
- :
- // If we've posted or commented anonymously anywhere else
- // on the page, continue anonymously.
- // Otherwise, continue using our real name (since we've used our
- // real name previously, on this page).
- // Here, could make sense to explicitly [choose_alias] or derive
- // based on the [alias_mode].
- lastAnon || false
- );
-
- // Even if we'll by default continue as ourself (!doAsAnon), we might need an anonym
- // to show in the ChooseAnonModal dropdown.
- const anyAnon = doAsAnon || lastAnon ||
- anonsAllowed && ({ newAnonStatus: discProps.newAnonStatus } as NewAnon);
-
- // Distant future: [pseudonyms_later]
- // const anyPseudonyms: WhichPseudonym[] = ...
- // and also a way to:
- // openCreatePseudonymsDialog(..) ?
- // — there could be a Create Pseudonym button?
-
- const res: ChoosenAnon = {
- doAsAnon,
- myAliasOpts: anyAnon
- ? [false, anyAnon] // options are: ourself, or the anonym `anyAnon`
- : [false], // option is: we can only be ourself
- };
-
- if (then) {
+ // ----- Choose persona
+
+ // If we're unsure which alias (if any) the user wants to use, we'll open a dialog
+ // and ask. But if the caller didn't pass any `then()` fn, then, return the
+ // result immediately.
+
+ if (!then)
+ return res;
+
+ if (personaOpts.isAmbiguous) {
+ openChoosePersonaDiag({ atRect: ps.atRect,
+ personaOpts, me: ps.me, origins: ps.origins,
+ }, then);
+ }
+ else {
then(res);
}
- return res;
}
-let setStateExtFn: (_: ChooseAnonDlgPs) => Vo;
+/// A next-to-the-button-clicked dialog that asks the user which alias to use,
+/// if it's unclear. E.g. han has switched to Anonmous mode, but has commented
+/// on the page as hanself already. When posting another comment, does
+/// han want to be anonymous (because of Anonymous mode), or continue commenting
+/// as hanself? We'd better ask, not guess.
+///
+function openChoosePersonaDiag(ps: { atRect: Rect,
+ personaOpts: PersonaOptions, me: Me, origins: Origins, },
+ then?: (_: (DoAsAndOpts | 'CANCEL')) => V) {
+ morekit.openProxyDiag({ atRect: ps.atRect, flavor: DiagFlavor.Dialog,
+ onHide: () => { if (then) then('CANCEL'); }, dialogClassName: 'c_' },
+ (closeDiag: () => V) => {
+
+ const opts = ps.personaOpts.optsList.map(a => a.doAs);
+
+ function closeAndThen(doAsAnon: MaybeAnon) {
+ closeDiag();
+ then({ doAsAnon, myAliasOpts: opts });
+ }
+
+ let debugJson = null;
+ // @ifdef DEBUG
+ //debugJson = r.pre({}, JSON.stringify(ps, undefined, 3));
+ // @endif
+
+ // (This list might include more than one type of anonymous user, say, 1) temporarily
+ // anonymous and 2) permanently anonymous. This can happen if a user U replies as
+ // perm anon on a page in a perm anon category. But then an admin moves the page to
+ // a temp anon category. Now, user U replies again, but chooses to be temp anon, this
+ // time (which is allowed in this different category). [move_anon_page] [dif_anon_status])
+ return rFr({},
+ r.div({ className: 'esDropModal_header' },
+ `Do as ...`),
+ r.ol({},
+ ps.personaOpts.optsList.map((opt: PersonaOption) => {
+ const avatarElm = avatar.Avatar({
+ user: opt.alias, origins: ps.origins, ignoreClicks: true,
+ size: AvatarSize.Small });
+ return ExplainingListItem({
+ key: opt.alias.id,
+ title: pat_name(opt.alias),
+ img: avatarElm,
+ text: ambiguityToDescr(opt, ps.me),
+ tabIndex: 100,
+ onClick: () => closeAndThen(opt.doAs),
+ });
+ })),
+ debugJson);
+ });
+}
+
+
+function ambiguityToDescr(opt: PersonaOption, me: Me): RElm {
+ // UX SHOULD require 2 clicks to choose? At different coordinates. [deanon_risk] [mouse_slip]
+ const isMe = opt.alias.id === me.id;
+ const selfOrAnon = isMe ? "Yourself" : anonStatus_toStr( // [pseudonyms_later] I18N
+ opt.alias.anonStatus, Verbosity.Full);
+ const selfOrAnonLower = selfOrAnon.toLowerCase();
+
+ const part1 =
+ opt.inSameThread ?
+ // "Earlier", but not necessarily "above" (as in higher up on the page). Depends
+ // on the sort order.
+ r.span({}, `You are ${selfOrAnonLower} earlier in this thread`) : (
+ opt.onSamePage ?
+ r.span({}, `You are ${selfOrAnonLower} elsewhere on this page`) : (
+ null));
+
+ let part2 = part1 ? '.' : '';
+ if (opt.isFromMode) {
+ if (!part1) part2 = `You're in ${selfOrAnon} mode.`;
+ else part2 = `, and you're in ${selfOrAnon} mode.`;
+ }
+
+ // Skip this — "You're recommended ..." sounds a bit paternalistic?
+ // (Would make sense if [the *recommended* persona] being different from e.g. [any previously
+ // *used* persona] made the choose-persona dialog to appear (`PersonaOptions.isAmbiguous`)
+ // — but that's no longer the case.)
+ //if (opt.isRecommended) {
+ // part3 = ` You're recommended to be ${selfOrAnon} here.`;
+ //}
+
+ const part4 = part2 || !isMe ? '' : "Yourself";
+
+ const part5 = opt.isNotAllowed ?
+ ` However, you cannot be ${selfOrAnonLower} here.` : '';
+
+ return rFr({}, part1, part2, part4, part5);
+}
+
+
+function addDraftAuthor_inPl(draft: Draft, doAsOpts: DoAsAndOpts) {
+ dieIf(!isVal(draft.doAsAnon), 'TyE3076MSRDw') // [oneself_0_false]
+
+ // Let's add the persona pat has already choosen as the author, to doAsOpts, if missing.
+ // (Not impossible the server will refuse to save the post — maybe anonymous
+ // comments have been disabled, for example. Then, the user can choose another persona,
+ // and try again.)
+ let optFound: MaybeAnon | U;
+ for (let opt of doAsOpts.myAliasOpts) {
+ if (opt === false || draft.doAsAnon === false) { // false is oneself [oneself_0_false]
+ if (opt === draft.doAsAnon) {
+ optFound = opt;
+ break;
+ }
+ }
+ else {
+ // We know it's WhichAnon, since is not oneself (tested above). [pseudonyms_later]
+ const optAlias = opt as WhichAnon;
+ const draftAlias = draft.doAsAnon as WhichAnon;
+ if (optAlias.sameAnonId || draftAlias.sameAnonId) {
+ if (optAlias.sameAnonId === draftAlias.sameAnonId) {
+ optFound = opt;
+ break;
+ }
+ }
+ else if (optAlias.anonStatus || draftAlias.anonStatus) {
+ if (optAlias.anonStatus === draftAlias.anonStatus) {
+ // Must be `WhichAnon.lazyCreate` — `createNew_tst` hasn't been implemented.
+ optFound = opt;
+ break;
+ }
+ }
+ else {
+ // Can't happen, until later when implementing pseudonyms.
+ }
+ }
+ }
+
+ // If [the alias pat is using as author of the draft] isn't among the current options,
+ // add it.
+ if (!isVal(optFound)) { // [oneself_0_false]
+ doAsOpts.myAliasOpts.push(draft.doAsAnon);
+ optFound = draft.doAsAnon;
+ }
-export function openAnonDropdown(ps: ChooseAnonDlgPs) {
+ // Continue using the same author, for this draft, as before.
+ // (`optFound` also includes any anon status, so use it. But `draft.doAsAnon`
+ // might not incl the anon status — it'd be better if it did, see [chk_alias_status].)
+ doAsOpts.doAsAnon = optFound;
+}
+
+
+
+// ----------------------------------------------------------------------------
+// REFACTOR:
+// The rest of this file, should be in its own file? ChoosePersonaDropdown.ts?
+
+
+let setStateExtFn: (_: ChoosePersonaDlgPs) => V;
+
+export function openAnonDropdown(ps: ChoosePersonaDlgPs) {
if (!setStateExtFn) {
ReactDOM.render(ChooseAnonModal(), utils.makeMountNode()); // or [use_portal] ?
}
@@ -187,6 +407,7 @@ export function openAnonDropdown(ps: ChooseAnonDlgPs) {
}
+
/// Some dupl code? [6KUW24] but this with React hooks.
///
/// Or use instead: client/app-more/page-dialogs/add-remove-people-dialogs.more.ts ?
@@ -200,7 +421,7 @@ const ChooseAnonModal = React.createFactory<{}>(function() {
// TESTS_MISSING
- const [state, setState] = React.useState(null);
+ const [state, setState] = React.useState(null);
setStateExtFn = setState;
@@ -223,7 +444,8 @@ const ChooseAnonModal = React.createFactory<{}>(function() {
const active =
// `whichAnon` and `state.curAnon` can be false, or a WhichAnon object.
any_isDeepEqIgnUndef(whichAnon, state.curAnon);
- const status = whichAnon && (whichAnon.anonStatus || whichAnon.newAnonStatus);
+ // [oneself_0_false] `&&` won't work with `{ self: true }`.
+ const status = whichAnon && whichAnon.anonStatus;
return (
ExplainingListItem({
title, text, active, key: whichAnon ? whichAnon.sameAnonId || 'new' : 'self',
@@ -239,7 +461,7 @@ const ChooseAnonModal = React.createFactory<{}>(function() {
}));
}
- items = state.myAliasOpts.map(makeItem);
+ items = state.myAliasOpts.map(makeItem);// [ali_opts_only_needed_here]
}
return (
@@ -277,7 +499,8 @@ const enum TitleDescr {
function whichAnon_titleDescrImpl(doAs: MaybeAnon, ps: { me: Me, pat?: Pat }, // I18N
what: TitleDescr): St | RElm {
- const anonStatus = doAs ? doAs.anonStatus || doAs.newAnonStatus : AnonStatus.NotAnon;
+ // [oneself_0_false] `?` won't work with `{ self: true }`.
+ const anonStatus = doAs ? doAs.anonStatus : AnonStatus.NotAnon;
// UX SHOULD if doAs.sameAnonId, then, show which anon (one might have > 1 on the
// same page) pat will continue posting as / using.
// But not a hurry? Right now one cannot have more than one anon per
diff --git a/client/app-more/page-dialogs/delete-post-dialog.more.ts b/client/app-more/page-dialogs/delete-post-dialog.more.ts
index 8a163e87f4..c6ee542124 100644
--- a/client/app-more/page-dialogs/delete-post-dialog.more.ts
+++ b/client/app-more/page-dialogs/delete-post-dialog.more.ts
@@ -31,11 +31,11 @@ const ModalFooter = rb.ModalFooter;
let deletePostDialog;
-export function openDeletePostDialog(post: Post, at: Rect) {
+export function openDeletePostDialog(ps: { post: Post, at: Rect, doAsAnon?: MaybeAnon }) {
if (!deletePostDialog) {
deletePostDialog = ReactDOM.render(DeletePostDialog(), utils.makeMountNode());
}
- deletePostDialog.open(post, at);
+ deletePostDialog.open(ps);
}
@@ -48,11 +48,12 @@ const DeletePostDialog = createComponent({
};
},
- open: function(post: Post, at: Rect) {
+ open: function(ps: { post: Post, at: Rect, doAsAnon?: MaybeAnon }) {
this.setState({
isOpen: true,
- post: post,
- atRect: at,
+ post: ps.post,
+ atRect: ps.at,
+ doAsAnon: ps.doAsAnon,
windowWidth: window.innerWidth,
});
},
@@ -62,7 +63,7 @@ const DeletePostDialog = createComponent({
},
doDelete: function() {
- ReactActions.deletePost(this.state.post.nr, this._delRepls, this.close);
+ ReactActions.deletePost(this.state.post.nr, this._delRepls, this.state.doAsAnon, this.close);
},
render: function () {
diff --git a/client/app-more/page-tools/page-tools.more.ts b/client/app-more/page-tools/page-tools.more.ts
index d613fedf1c..7e5463455e 100644
--- a/client/app-more/page-tools/page-tools.more.ts
+++ b/client/app-more/page-tools/page-tools.more.ts
@@ -66,16 +66,6 @@ const PageToolsDialog = createComponent({
ReactActions.unpinPage(this.close);
},
- deletePage: function() {
- const store: Store = this.state.store;
- ReactActions.deletePages([store.currentPageId], this.close);
- },
-
- undeletePage: function() {
- const store: Store = this.state.store;
- ReactActions.undeletePages([store.currentPageId], this.close);
- },
-
render: function () {
const store: Store = this.state.store;
const me: Myself = store.me;
@@ -88,8 +78,8 @@ const PageToolsDialog = createComponent({
//let selectPostsButton = !store_canSelectPosts(store) ? null :
//Button({ onClick: this.selectPosts }, "Select posts");
- let pinPageButton;
- let pinPageDialog;
+ let pinPageButton: RElm | U;
+ let pinPageDialog: RElm | U;
if (store_canPinPage(store)) {
pinPageDialog = PinPageDialog(_.assign({ ref: 'pinPageDialog' }, childProps));
pinPageButton =
@@ -101,10 +91,12 @@ const PageToolsDialog = createComponent({
Button({ onClick: this.unpinPage, className: 'e_UnpinPg' }, "Unpin Topic");
const deletePageButton = !store_canDeletePage(store) ? null :
- Button({ onClick: this.deletePage, className: 'e_DelPg' }, "Delete Topic");
+ DeletePageBtn({ pageIds: [store.currentPageId], store,
+ verb: Verbosity.Full, close: this.close });
const undeletePageButton = !store_canUndeletePage(store) ? null :
- Button({ onClick: this.undeletePage, className: 'e_RstrPg' }, "Restore Topic");
+ DeletePageBtn({ pageIds: [store.currentPageId], store, undel: true,
+ verb: Verbosity.Full, close: this.close });
const idsAndUrlsButton = page.pageRole !== PageRole.EmbeddedComments || !me.isAdmin ? null :
Button({ onClick: () => openPageIdsUrlsDialog(page.pageId), className: 'e_PgIdsUrls' },
diff --git a/client/app-more/persona/persona-info-diag.ts b/client/app-more/persona/persona-info-diag.ts
new file mode 100644
index 0000000000..628868bef2
--- /dev/null
+++ b/client/app-more/persona/persona-info-diag.ts
@@ -0,0 +1,297 @@
+/*
+ * Copyright (c) 2024 Kaj Magnus Lindberg
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * 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
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+///
+///
+
+//------------------------------------------------------------------------------
+ namespace debiki2.persona {
+//------------------------------------------------------------------------------
+
+const r = ReactDOMFactories;
+
+
+export function openPersonaInfoDiag(ps: { atRect: Rect, isSectionPage: Bo,
+ me: Me, personaOpts: PersonaOptions, discProps: DiscPropsDerived }): V {
+
+ const discProps: DiscPropsDerived = ps.discProps;
+ const me: Me = ps.me;
+
+ // This'll be page_users3.prefer_alias_id_c once implemented.
+ //const thisPagePrefAlias: KnownAnonym | Me | N = null;
+
+ //const pseudonymsRecommended = false; [pseudonyms_later]
+ const anonsAllowed = discProps.comtsStartAnon >= NeverAlways.Allowed;
+ const anonsRecommended = discProps.comtsStartAnon >= NeverAlways.Recommended;
+ const mustBeAnon = discProps.comtsStartAnon >= NeverAlways.AlwaysButCanContinue;
+ const switchedToPseudonym = false;
+ const switchedToAnonStatus: AnonStatus | U = me.usePersona && me.usePersona.anonStatus;
+ const switchedToSelf = me.usePersona && me.usePersona.self;
+
+ morekit.openProxyDiag({ atRect: ps.atRect, flavor: DiagFlavor.Dialog,
+ dialogClassName: 'e_PersInfD' }, (closeDiag: () => V) => {
+
+ const inThisPlace = ps.isSectionPage ? "in this category" : "on this page";
+ const thereforeIllAsk = "Therefore, I'll ask you if you want to be yourself, " +
+ "or be anonymous, if you post something here.";
+
+ // Either: "But you have also posted as ..."
+ // Or: "You have posted both as ... and as ..."
+ const hasBeenBoth = ps.personaOpts.hasBeenAnon && ps.personaOpts.hasBeenSelf;
+ const butYou = hasBeenBoth ? "You" : "But you";
+ const also = hasBeenBoth ? '' : "also ";
+ const both = hasBeenBoth ? "both " : '';
+
+ let whatMode: St | U;
+ let content: RElm;
+ let enterSelfModeBtn: RElm | U;
+ let enterAnonModeBtn: RElm | U;
+ let okPersona = true;
+
+ if (switchedToSelf) { // [alias_mode]
+ whatMode = "Yourself"; // I18N, this whole fn
+
+ const youreInSelfMode = rFr({}, "You're in ", r.b({}, "Yourself mode"));
+ const colonPostingAsSelf = ": You're posting and voting as yourself.";
+
+ const ambigMsg = !ps.personaOpts.hasBeenAnon ? null : rFr({},
+ r.p({},
+ `${butYou} have ${also}posted ${both}anonymously${
+ both ? ", and as yourself," : ''} ${inThisPlace}.`),
+ r.p({},
+ thereforeIllAsk));
+
+ if (mustBeAnon && ps.personaOpts.hasBeenSelf) {
+ // 1: _May_not_but_can_continue
+ // One can continue as oneself, if one has commented as oneself already before
+ // this page/category became anonymous-only.
+ content = rFr({},
+ r.p({}, youreInSelfMode, '.'),
+ r.p({},
+ `Normally, you cannot post as yourself ${inThisPlace}, ` +
+ "but you have done that already; therefore you can continue as yourself."));
+ }
+ else if (mustBeAnon) {
+ // 2: _May_not
+ okPersona = false;
+ content = r.p({},
+ youreInSelfMode, ". But you ", r.b({}, "cannot"),
+ " post as yourself here — everyone needs to be anonymous.");
+ }
+ else if (anonsAllowed) { // [pseudonyms_later]
+ // 3: _Can_choose
+ content = rFr({},
+ r.p({}, youreInSelfMode, colonPostingAsSelf),
+ ambigMsg || r.p("You can be anonymous, though, if you want."));
+ }
+ else {
+ // 4: _Everyone_is
+ content = rFr({},
+ r.p({}, youreInSelfMode, colonPostingAsSelf),
+ ambigMsg ||
+ // Maybe a bit interesting that Yourself mode isn't needed, here:
+ // (But one can continue posting anonymously if one has done that already,
+ // on the current page.)
+ r.p({}, "(On this page, you cannot be anonymous anyway.)"),
+ );
+ }
+ }
+ else if (switchedToAnonStatus) {
+ whatMode = anonStatus_toStr(switchedToAnonStatus);
+ const youreInAnonMode = rFr({}, "You're in ", r.b({}, whatMode + " mode"));
+ const colonPostingAnonly = ": You're posting and voting anonymously.";
+ const ambigMsg = !ps.personaOpts.hasBeenSelf ? null : rFr({},
+ r.p({},
+ `${butYou} have ${also}posted ${both}as yourself${
+ both ? ", and anonymously," : ''} ${inThisPlace}.`),
+ r.p({},
+ thereforeIllAsk));
+
+ // (Maybe not the same anon status. [dif_anon_status])
+ if (!anonsAllowed && ps.personaOpts.hasBeenAnon) {
+ // 1: _May_not_but_can_continue
+ // One can continue anonymously if one has been anonymous on this page already,
+ // some time ago when that was allowed (or if the page got moved to another category?).
+ content = rFr({},
+ r.p({}, youreInAnonMode, '.',
+ r.p({},
+ `Normally, you cannot post anonymously ${inThisPlace}, ` +
+ `but you have done that already; therefore you can continue.`)));
+ }
+ else if (!anonsAllowed) {
+ // 2: _May_not
+ okPersona = false;
+ content = r.p({},
+ youreInAnonMode, ". But you ", r.b({}, "cannot"), " be anonymous here.");
+ }
+ else if (!mustBeAnon) { // [pseudonyms_later]
+ // 3: _Can_choose
+ // (Maybe not the same type of anonyms? [dif_anon_status])
+ content = rFr({},
+ r.p({}, youreInAnonMode, colonPostingAnonly),
+ ambigMsg || (!anonsRecommended ? '' :
+ r.p({}, ` Everyone is anonymous ${inThisPlace} by default.`)));
+ }
+ else {
+ // 4: _Everyone_is
+ // (Maybe not the same type of anonyms? [dif_anon_status])
+ content = rFr({},
+ r.p({}, youreInAnonMode, colonPostingAnonly),
+ ambigMsg ||
+ r.p({}, `Everyone is anonymous ${inThisPlace} anyway.`));
+ }
+ }
+ else if (switchedToPseudonym) {
+
+ // @ifdef DEBUG
+ die('TyE206MFKG'); // [pseudonyms_later]
+ // @endif
+ void 0;
+ /*
+ whatMode = "Pseudonymous Mode as user (S) Some Psuedonym";
+ if (mustBeAnon || !pseudonymsAllowed) {
+ okAlias = false;
+ content = rFr({}, "You're in pseudonymous mode, but you cannot use pseudonyms here" +
+ (mustBeAnon ? " — everyone must be anonymous." : '.'));
+ }
+ else {
+ content = r.span({}, "You're using a pseudonym, (P) some_pseudonym, which will " +
+ "be used if you post topics or comments, or upvote anything." +
+ (!ambiguity ? '' : "However, you have also posted anonymously here, " +
+ "and I'll need to ask you if you want to use the pseudonym, or " +
+ "continue anonymously."));
+ }
+ */
+ }
+ else {
+ const whenYouBlaBla = "when you post comments or upvote others.";
+ if (ps.personaOpts.hasBeenSelf && ps.personaOpts.hasBeenAnon) {
+ content = rFr({},
+ r.p({}, "You have been both anonymous and yourself on this page. "),
+ r.p({}, thereforeIllAsk));
+ }
+ else if (ps.personaOpts.hasBeenSelf) {
+ content = rFr({},
+ r.p({},
+ "You have been yourself on this page, so, you'll continue " +
+ "being yourself, " + whenYouBlaBla),
+ !anonsAllowed ? null : r.p({},
+ "You can be anonymous instead, if you want."));
+ // There'll be an [Enter Anonymous mode] button below (if allowed here)
+ // – that's clear enough? (No need for "Click ... below if ...".)
+ }
+ else if (ps.personaOpts.hasBeenAnon) {
+ // COULD: If has been both temp anon and permanently anon, say sth like:
+ // "You have been both temporarily and permanently anonymous on this page."
+ // + thereforeIllAsk. [dif_anon_status]
+ content = rFr({},
+ r.p({},
+ "You have been anonymous on this page, so, you'll continue " +
+ "being anonymous, " + whenYouBlaBla),
+ mustBeAnon ? null: r.p({},
+ "You can post as yourself instead, if you want."));
+ // There'll be an [Enter Yourself mode] button below (if allowed).
+ }
+ else if (anonsRecommended) {
+ // Say "temporarily anonymous" if temp anon. [dif_anon_status]
+ content = r.p({},
+ `You and others are anonymous by default, ${inThisPlace}.`);
+ } /*
+ else if (pseudonymsRecommended) {
+ // [pseudonyms_later]
+ // How's this going to work? Can't create pseudonyms automatically – pat needs
+ // to choose a pseudonym name, hmm. Maybe a pop-up question like:
+ // "Create a pseudonym? If you don't want to use your real name, ... [Yes] [No]"
+ } */
+ else if (anonsAllowed) {
+ content = rFr({},
+ r.p({},
+ // Say "temporarily anonymous" if temp anon. [dif_anon_status]
+ `You can post anonymously here, if you want.`),
+ r.p({},
+ "By default, your posting as yourself though."));
+ }
+ else {
+ // Dead code? The personas info button shouldn't appear, if one cannot
+ // be and hasn't been anonymous.
+ content = r.p({},
+ `You're yourself — you cannot post anonymously here.`);
+ }
+
+ if (!mustBeAnon) enterSelfModeBtn =
+ mkBtn(r.span({},
+ "Enter ", r.u({}, "Yourself"), " mode"),
+ { self: true } satisfies Oneself);
+
+ if (anonsAllowed) enterAnonModeBtn =
+ mkBtn(r.span({},
+ "Enter ", r.u({}, anonStatus_toStr(discProps.newAnonStatus)), " mode"),
+ { anonStatus: discProps.newAnonStatus,
+ lazyCreate: true } satisfies LazyCreatedAnon);
+
+ // [pseudonyms_later] More buttons:
+ // [ Switch to (P) pseudonym_of_yours ]
+ // [ Switch to (S) some_other_pseudonym]
+ // [ Create Pseudonym ]
+ }
+
+ /* Sometimes incl cur page aliases in content? But when? Maybe if clicking a [More v]
+ // button, then, could show a list of all one's aliases on the current page, in the
+ // contextbar — there's already a "Users on this page" list there, see [users_here],
+ // and filtering out only one's own personas can make sense?
+ if (me.myAliasCurPage.length >= 1) {
+ const user = me.myAliasCurPage.length === 1 ? "user " : "users ";
+ content = rFr({},
+ "You are anonymous " + user,
+ rFr({},
+ me.myAliasCurPage.map((alias: KnownAnonym) =>
+ rFr({},
+ avatar.Avatar({ key: alias.id, user: alias, origins: store }),
+ r.span({ className: '' }, alias.username || alias.fullName)))),
+ "on this page." + everyoneIs);
+ } */
+
+ const leaveModeBtn = !whatMode ? null :
+ mkBtn(r.span({}, "Leave ", r.u({}, whatMode), " mode"), null);
+
+ function mkBtn(title: St | RElm, usePersona: Oneself | LazyCreatedAnon | N): RElm {
+ return Button({ onClick: () => {
+ ReactActions.patchTheStore({ me: { usePersona } });
+ closeDiag();
+ }}, title);
+ }
+
+ return rFr({},
+ // Better without any header. The mode is in bold in the first sentence already,
+ // feels like enough.
+ // r.div({ className: 'esDropModal_header' }, !whatMode ? '' : whatMode + " Mode"),
+ r.div({}, content),
+ r.div({ className: 'c_PersInfD_Bs' },
+ leaveModeBtn,
+ enterSelfModeBtn,
+ enterAnonModeBtn),
+ );
+ });
+}
+
+
+
+
+//------------------------------------------------------------------------------
+ }
+//------------------------------------------------------------------------------
+// vim: fdm=marker et ts=2 sw=2 tw=0 fo=tcqwn list
diff --git a/client/app-more/users/users-page.more.ts b/client/app-more/users/users-page.more.ts
index 7fdbf78ea1..9b7b4a5080 100644
--- a/client/app-more/users/users-page.more.ts
+++ b/client/app-more/users/users-page.more.ts
@@ -455,7 +455,7 @@ const PatTopPanel = createComponent({
let isWhatInfo: St | N = null;
if (user.isAnon) {
- isWhatInfo = t.Anonym || "Anonym";
+ isWhatInfo = anonStatus_toStr(user.anonStatus);
}
else if (isGuest(user)) {
isWhatInfo = t.upp.isGuest;
diff --git a/client/app-more/util/stupid-dialog.more.ts b/client/app-more/util/stupid-dialog.more.ts
index 84a58de414..6de8f9b599 100644
--- a/client/app-more/util/stupid-dialog.more.ts
+++ b/client/app-more/util/stupid-dialog.more.ts
@@ -64,6 +64,7 @@ export function openDefaultStupidDialog(stuff: StupidDialogStuff) {
}
+// Much later: Remove, and [replace_stupid_diag_w_simple_proxy_diag].
export const StupidDialog = createComponent({
displayName: 'StupidDialog',
diff --git a/client/app-more/widgets.more.ts b/client/app-more/widgets.more.ts
index ab0daa1f61..937f1ca9f6 100644
--- a/client/app-more/widgets.more.ts
+++ b/client/app-more/widgets.more.ts
@@ -94,7 +94,7 @@ export const GroupList = function(member: UserDetailsStatsGroups, groupsMaySee:
export const Expandable = (
props: { header: any, onHeaderClick: any, isOpen?: boolean,
className?: string, openButtonId?: string },
- ...children) => {
+ ...children): RElm => {
let body = !props.isOpen ? null :
r.div({ className: 's_Expandable_Body' }, children);
@@ -115,6 +115,27 @@ export const Expandable = (
};
+
+// Wouldn't it be better with a [pick_persona_click_handler]?
+export function DeletePageBtn(ps: { pageIds: PageId[], store: Store, undel?: true,
+ verb?: Verbosity, close: () => V }): RElm {
+
+ const page = ps.verb > Verbosity.Full ? " page" : '';
+ const title = (ps.undel ? "Undelete" : "Delete") + page; // I18N
+
+ return Button({ className: ps.undel ? 'e_UndelPgB' : 'e_DelPgB',
+ onClick: (event: MouseEvent) => {
+ const atRect = cloneEventTargetRect(event);
+ persona.chooseEditorPersona({ store: ps.store, atRect,
+ isInstantAction: true }, (doAsOpts: DoAsAndOpts) => {
+ const delOrUndel = ps.undel ? ReactActions.undeletePages : ReactActions.deletePages;
+ delOrUndel({
+ pageIds: ps.pageIds, doAsAnon: doAsOpts.doAsAnon }, ps.close);
+ });
+ } },
+ title);
+}
+
//------------------------------------------------------------------------------
}
//------------------------------------------------------------------------------
diff --git a/client/app-more/widgets/anon-purpose-btn.more.ts b/client/app-more/widgets/anon-purpose-btn.more.ts
index 3fd5abd626..8a7f5c4076 100644
--- a/client/app-more/widgets/anon-purpose-btn.more.ts
+++ b/client/app-more/widgets/anon-purpose-btn.more.ts
@@ -72,7 +72,7 @@ function openAnonPurposeDiag(ps: DiscLayoutDiagState) {
dialogClassName: 'e_AnonPurpD' },
(closeDiag) => {
- const layout: DiscPropsSource | NU = ps.layout;
+ const layout: DiscPropsSource = ps.layout;
let diagTitle: St | RElm | U;
let sensitiveItem: RElm | U;
@@ -86,7 +86,7 @@ function openAnonPurposeDiag(ps: DiscLayoutDiagState) {
function makeItem(itemValue: AnonStatus, e2eClass: St): RElm {
let active: Bo;
let title: St | RElm;
- active = itemValue === ps.layout.newAnonStatus;
+ active = itemValue === layout.newAnonStatus;
title = anonPurpose_title(itemValue);
return ExplainingListItem({
diff --git a/client/app-slim/ReactActions.ts b/client/app-slim/ReactActions.ts
index 2bee3712c1..0cc5af0a32 100644
--- a/client/app-slim/ReactActions.ts
+++ b/client/app-slim/ReactActions.ts
@@ -262,31 +262,31 @@ export function unpinPage(success: () => void) {
}
-export function deletePages(pageIds: PageId[], success: () => void) {
- Server.deletePages(pageIds, () => {
- success();
+export function deletePages(ps: { pageIds: PageId[], doAsAnon: MaybeAnon }, onOk: () => V) {
+ Server.deletePages(ps, () => {
+ onOk();
// CLEAN_UP REFACTOR use patchTheStore( StorePatch.deletedPageIds ) instead
ReactDispatcher.handleViewAction({
actionType: actionTypes.DeletePages,
- pageIds: pageIds,
+ pageIds: ps.pageIds,
});
});
}
-export function undeletePages(pageIds: PageId[], success: () => void) {
- Server.undeletePages(pageIds, () => {
- success();
+export function undeletePages(ps: { pageIds: PageId[], doAsAnon: MaybeAnon }, onOk: () => V) {
+ Server.undeletePages(ps, () => {
+ onOk();
ReactDispatcher.handleViewAction({
actionType: actionTypes.UndeletePages,
- pageIds: pageIds,
+ pageIds: ps.pageIds,
});
});
}
-export function togglePageClosed(doAsAnon: MaybeAnon, onDone?: () => V) {
- Server.togglePageClosed(doAsAnon, (closedAtMs) => {
+export function togglePageClosed(ps: { pageId: PageId, doAsAnon: MaybeAnon }, onDone?: () => V) {
+ Server.togglePageClosed(ps, (closedAtMs) => {
ReactDispatcher.handleViewAction({
actionType: actionTypes.TogglePageClosed,
closedAtMs: closedAtMs
@@ -298,19 +298,19 @@ export function togglePageClosed(doAsAnon: MaybeAnon, onDone?: () => V) {
}
-export function acceptAnswer(postId: number, doAsAnon: MaybeAnon) {
- Server.acceptAnswer(postId, doAsAnon, (answeredAtMs) => {
+export function acceptAnswer(ps: { pageId: PageId, postId: PostId, doAsAnon: MaybeAnon }) {
+ Server.acceptAnswer(ps, (answeredAtMs) => {
ReactDispatcher.handleViewAction({
actionType: actionTypes.AcceptAnswer,
answeredAtMs: answeredAtMs,
- answerPostUniqueId: postId,
+ answerPostUniqueId: ps.postId,
});
});
}
-export function unacceptAnswer(doAsAnon: MaybeAnon) {
- Server.unacceptAnswer(doAsAnon, () => {
+export function unacceptAnswer(ps: { pageId: PageId, doAsAnon: MaybeAnon }) {
+ Server.unacceptAnswer(ps, () => {
unacceptAnswerClientSideOnly();
});
}
@@ -392,9 +392,10 @@ export function setPostHidden(postNr: number, hide: boolean, success?: () => voi
}
-export function deletePost(postNr: number, repliesToo: boolean, success: () => void) {
- Server.deletePostInPage(postNr, repliesToo, (response: { deletedPost, answerGotDeleted }) => {
- success();
+export function deletePost(postNr: PostNr, repliesToo: Bo,
+ doAsAnon: MaybeAnon | U, onOk: () => V) {
+ Server.deletePostInPage(postNr, repliesToo, doAsAnon, (response: { deletedPost, answerGotDeleted }) => {
+ onOk();
ReactDispatcher.handleViewAction({
actionType: actionTypes.UpdatePost,
post: response.deletedPost
@@ -997,6 +998,8 @@ function markAnyNotificationAsSeen(postNr: number) {
}
+// A separate function here, so accessible also via embedded iframes (rather than
+// inlined in editor.editor.ts)
export function onEditorOpen(ps: EditorStorePatch, onDone?: () => void) {
if (eds.isInEmbeddedEditor) {
sendToCommentsIframe(['onEditorOpen', ps]);
@@ -1578,6 +1581,18 @@ function loadAndShowNewPage(newUrlPath: St, history: ReactRouterHistory) {
const pats = _.values(newStore.usersByIdBrief);
const pubCats = newStore.publicCategories;
+ // COULD_OPTIMIZE If listing recently active topics, they're already
+ // included in the page json:
+ // - newStore.topics,
+ // - newStore.me.restrictedTopics
+ // - newStore.me.restrictedTopicsUsers
+ // Add them to the store, as is done here: [add_restr_topics], then, can skip
+ // a request to the server to list recent topics.
+ //
+ // Currently we do add `newStore.me.restrictedCategories` (see
+ // store_addRestrictedCurCatsInPl()) – otherwise access restricted pages
+ // wouldn't render (would be category missing errors).
+
// This'll trigger ReactStore onChange() event; everything will redraw to show the new page.
showNewPage({
newPage,
diff --git a/client/app-slim/ReactStore.ts b/client/app-slim/ReactStore.ts
index 6a1778cc0d..13e099bb0d 100644
--- a/client/app-slim/ReactStore.ts
+++ b/client/app-slim/ReactStore.ts
@@ -59,6 +59,16 @@ const storeEventListeners: StoreEventListener[] = [];
// Read-only hooks based store state. WOULD REFACTOR make it read-write and delete ReactActions,
// and remove EventEmitter too? [4WG20ABG2] Have all React code use `useStoreState`
// instead of that old "flux" stuff.
+//
+// This is similar to useReducer() and accessing the state via useContext() — see:
+// https://react.dev/learn/scaling-up-with-reducer-and-context,
+// `useStoreState()` corresponds to `useTasks()` in their example.
+// Hmm, they've separated only-*using*-the-state and *changing* the state into
+// `useTasks()` and `useTasksDispatch()`. Then it could be nice if `useStoreState()`
+// also was only for *using* the state — which is how it already works actually,
+// I'll just need to remove the can't-use-anyway `setState()` return value.
+// And a new hook function, `useStoreActions()` for *doing* things?
+//
export function useStoreState(): [Store, () => void] {
const [state, setState] = React.useState(store);
@@ -779,6 +789,8 @@ ReactStore.activateMyself = function(anyNewMe: Myself | NU, stuffForMe?: StuffFo
// Show the user's own unapproved posts, or all, for admins.
store_addAnonsAndUnapprovedPosts(store, myPageData); // TyTE2E603SKD
+ // Add any topics not included in the cached json, because they're access
+ // restricted (but the current user can see them). [add_restr_topics]
if (_.isArray(store.topics)) {
const currentPage: Page = store.currentPage;
store.topics = store.topics.concat(store.me.restrictedTopics);
@@ -827,6 +839,22 @@ ReactStore.activateMyself = function(anyNewMe: Myself | NU, stuffForMe?: StuffFo
}, 0);
}
+ // ----- Personas [update_personas]
+
+ // Later, if remembering any persona mode across page reloads, and different
+ // browser tabs: [remember_persona_mode]
+ // const asPersona = Server.getPersonaCookie();
+ // if (asPersona) {
+ // store.me.usePersona = asPersona;
+ // }
+
+ // Remember which aliases pat has used when posting or voting on the current page,
+ // so we can know who pat wants to be now, or if we need to ask.
+ // Do here, after `me.usePersona`, unapproved posts and access restricted cats inited.
+ store_updatePersonaOpts(store);
+
+ // ----- Websocket
+
debiki2.pubsub.subscribeToServerEvents(store.me);
store.quickUpdate = false;
};
@@ -1091,6 +1119,8 @@ function updatePost(post: Post, pageId: PageId, isCollapsing?: boolean) {
// Add or update the post itself.
page.postsByNr[post.nr] = post;
+ // Should remember any new persona, if new post? Or earlier, when adding pat? [update_personas]
+
const layout: DiscPropsDerived = page_deriveLayout(page, store, LayoutFor.PageWithTweaks);
// In case this is a new post, update its parent's child id list.
@@ -1161,7 +1191,7 @@ function voteOnPost(action: {
doWhat: 'DeleteVote' | 'CreateVote',
voteType: PostVoteType,
postNr: PostNr,
- voter: Pat}) {
+ voter: Pat}) { // CLEAN_UP: redundant, already in the storePatch.yourAnon, or is `me`.
const postNr: PostNr = action.postNr;
@@ -1174,14 +1204,32 @@ function voteOnPost(action: {
}
if (action.doWhat === 'CreateVote') {
+ const voter: Pat = action.voter;
votes.push({
// The voter might be the current user's anonym, so we need the voter id.
- byId: action.voter.id,
+ byId: voter.id,
type: action.voteType,
});
+
+ // The voter might be a just-now lazy-created anonym – then remember it. [lazy_anon_voter]
+ if (voter.isAnon && voter.anonForId) {
+ // @ifdef DEBUG
+ dieIf(!voter.anonStatus, 'TyE206MLP4');
+ // Page id currently not added server side, so skip: [see_own_alias]
+ // dieIf(voter.anonOnPageId !== myPageData.pageId, 'TyE206MLP5');
+ // @endif
+
+ const isNewAnon = !myPageData.knownAnons.find(a => a.id === voter.id);
+ if (isNewAnon) {
+ myPageData.knownAnons.push(voter as KnownAnonym);
+ }
+ }
}
else {
_.remove(votes, v => v.type === action.voteType);
+
+ // (We could update myPageData.knownAnons, if this vote was the only thing
+ // the anon has done on the page. But not really needed.)
}
// If this vote is the user's first action on the page, and han is voting
@@ -1725,6 +1773,7 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl
if (storePatch.me) {
// [redux] modifying the store in place, again.
+ let personaBefore = store.me.usePersona;
let patchedMe: Myself | U;
if (eds.isInIframe) {
// Don't forget [data about pat] loaded by other frames. [mny_ifr_pat_dta]
@@ -1732,6 +1781,7 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl
const sessWin = getMainWin();
const sessStore: SessWinStore = sessWin.theStore;
if (_.isObject(sessStore.me)) {
+ personaBefore ||= sessStore.me.usePersona;
patchedMe = me_merge(sessStore.me, store.me, storePatch.me); // [emb_ifr_shortcuts]
sessStore.me = _.cloneDeep(patchedMe);
}
@@ -1744,6 +1794,12 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl
patchedMe = _.assign(store.me || {} as Myself, storePatch.me);
}
store.me = patchedMe;
+
+ // Maybe later: [remember_persona_mode]
+ // const personaAfter = patchedMe.usePersona;
+ // if (!any_isDeepEqIgnUndef(personaBefore, personaAfter)) {
+ // Server.setPersonaCookie(personaAfter);
+ // }
}
// ----- Drafts
@@ -1797,6 +1853,11 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl
addBackRecentTopicsInPl(oldCurCats, store.curCatsById);
}
+ // If we're on a forum homepage (or other topic index page), and selected a different
+ // forum category to list topics in.
+ if (storePatch.listingCatId)
+ store.listingCatId = storePatch.listingCatId;
+
// ----- Tag types
// @ifdef DEBUG
@@ -1864,6 +1925,9 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl
dieIf(store.currentPageId !== currentPage.pageId, 'EdE7GBW2');
currentPage.pageId = storePatch.newlyCreatedPageId;
store.currentPageId = storePatch.newlyCreatedPageId;
+ // This not needed though – we didn't jump to a different page; we just got an
+ // id for the current page, which didn't exist but now does.
+ //store.currentPage = currentPage; // not needed
// This'll make the page indexed by both EmptyPageId and newlyCreatedPageId:
// (Could remove the NoPageId key? But might cause some bug?)
@@ -1936,16 +2000,16 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl
store_relayoutPageInPlace(store, currentPage, layoutAfter);
}
+ // ----- Posts, new or edited?
+
// Update the current page.
if (!storePatch.pageVersionsByPageId) {
// No page. Currently storePatch.usersBrief is for the current page (but there is none)
// so ignore it too.
- return;
}
-
- // ----- Posts, new or edited?
-
- _.each(store.pagesById, patchPage);
+ else {
+ _.each(store.pagesById, patchPage);
+ }
function patchPage(page: Page) {
const storePatchPageVersion = storePatch.pageVersionsByPageId[page.pageId];
@@ -1979,6 +2043,10 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl
Server.markCurrentPageAsSeen();
}
}
+
+ // [update_personas], here after both comments & the user's persona mode
+ // have been patched.
+ store_updatePersonaOpts(store);
}
@@ -2033,9 +2101,16 @@ function showNewPage(ps: ShowNewPageParams) {
delete store.curPageTweaks;
+ if (isSection(newPage)) {
+ store.listingCatId = newPage.categoryId;
+ }
+ else {
+ delete store.listingCatId;
+ }
+
// Forget any topics from the original page load. Maybe we're now in a different sub community,
// or some new topics have been created. Better reload.
- store.topics = null;
+ delete store.topics;
let myData: MyPageData;
if (ps.me) {
@@ -2071,6 +2146,8 @@ function showNewPage(ps: ShowNewPageParams) {
const oldCurCats = store.currentCategories;
store_initCurCatsFromPubCats(store);
+ // Could add access restricted topics & users too [add_restr_topics],
+ // iff we're listing the most recently active topics.
if (ps.me) {
store_addRestrictedCurCatsInPl(store, ps.me.restrictedCategories);
// Not needed? But anyway, less confusing if is updated too:
@@ -2171,6 +2248,12 @@ function showNewPage(ps: ShowNewPageParams) {
// Like: addLocalStorageDataTo(me, isNewPage = true);
addMyDraftPosts(store, store.me.myCurrentPageData);
+ // ----- Personas [update_personas]
+
+ // Remember which aliases pat has used when posting or voting on the current page,
+ // so we can know who pat wants to be now, or if we need to ask.
+ store_updatePersonaOpts(store);
+
// ----- Misc & "hacks"
// Make Back button work properly.
@@ -2198,6 +2281,96 @@ function showNewPage(ps: ShowNewPageParams) {
}
+/// REFACTOR: Split this fn into two: store_updateDiscProps(), and store_updatePersonaOpts()?
+/// The former makes sense also if not logged in; the latter does not (in effect, a "big noop").
+///
+function store_updatePersonaOpts(store: Store) {
+ if (!store.userSpecificDataAdded)
+ return;
+
+ const me = store.me;
+
+ // Later: The server incls this in the show-page response? [fetch_alias]
+ const myPersonasThisPage: MyPersonasThisPage =
+ disc_findMyPersonas(store, { forWho: me });
+
+ // Use which category properties?
+ // If we're on a forum homepage, and viewing topics in an anonymous-by-default
+ // category, we'd want the persona options to include posting-anonymously,
+ // so we need that category's properties.
+ const isSectionPage = !store.currentPage ? false : isSection(store.currentPage.pageRole);
+ const listingCat = store.listingCatId && store.currentCategories.find(
+ c => c.id === store.listingCatId);
+ const discProps: DiscPropsDerived | U = isSectionPage
+ ? (listingCat
+ ? // We're on a forum homepage, looking at a specific category to see
+ // topics in that cat.
+ cat_deriveLayout(listingCat, store, LayoutFor.PageNoTweaks)
+ : // If we don't have access to the category (then, `listingCat` missing).
+ // Or on initial page load, until startStuff() more done.
+ // Or if on All Cats page: [site_disc_props]
+ undefined)
+ : (store.currentPage
+ ? page_deriveLayout(store.currentPage, store, LayoutFor.PageNoTweaks)
+ : // [pseudonyms_later] Let people switch to a pseudonym also when they
+ // post direct messages? Look at a whole-site-default setting, to know
+ // if that should be allowed or not? [site_disc_props]
+ // (Since then they're on a user profile page, which
+ // isn't a discussion page so `currentPage` is absent.)
+ undefined); // if no page
+
+ const anonsRecommended = discProps && discProps.comtsStartAnon >= NeverAlways.Recommended;
+
+ const switchedToPseudonym = false; // [pseudonyms_later]
+ const switchedToAnonStatus: AnonStatus | NU = me.usePersona && me.usePersona.anonStatus;
+ const switchedToSelf = me.usePersona && (me.usePersona as Oneself).self;
+
+ const curPersonaOptions =
+ findPersonaOptions({ myPersonasThisPage, me, discProps });
+
+ const numOpts = curPersonaOptions.optsList.length;
+ const firstOpt: PersonaOption = curPersonaOptions.optsList[0];
+
+ let debug = false;
+ // @ifdef DEBUG
+ debug = true;
+ dieIf(numOpts < 1, 'TyEPSLS2M63');
+ // @endif
+
+ const mode: PersonaMode | U =
+ !me.id ? undefined : (
+ switchedToSelf ? { self: true } satisfies PersonaMode : (
+ switchedToAnonStatus ? { anonStatus: switchedToAnonStatus } satisfies PersonaMode : (
+ switchedToPseudonym ? die('TyE206MFKG') as any : (
+ // Anon or self, if we've been that before on this page?
+ firstOpt.alias.isAnon ?
+ { anonStatus: firstOpt.alias.anonStatus } satisfies PersonaMode : (
+ firstOpt.isSelf ? (
+ // If can't use, hasn't used, and hasn't switched to any alias,
+ // then, don't show any persona info – it's more likely off-topic
+ // and confusing.
+ numOpts <= 1 ? undefined : { self: true } satisfies PersonaMode) : (
+ anonsRecommended ? { anonStatus: discProps.newAnonStatus } satisfies PersonaMode :
+ // Dead code? Either `isSelf` or `isAnon` above should be tue.
+ (debug ? die(`TyE206SWl4`) : undefined)
+ // Could show "Self" if anons allowed, but not recommended?
+ // anonsAllowed ? { autoSelf: true } satisfies PersonaMode : undefined
+ ))))));
+
+ store.me.myCurrentPageData.myPersonas = myPersonasThisPage;
+ store.curDiscProps = discProps;
+
+ if (mode) {
+ store.curPersonaOptions = curPersonaOptions;
+ store.indicatedPersona = mode;
+ }
+ else {
+ delete store.curPersonaOptions;
+ delete store.indicatedPersona;
+ }
+}
+
+
function watchbar_markAsUnread(watchbar: Watchbar, pageId: PageId) {
watchbar_markReadUnread(watchbar, pageId, false);
}
@@ -2288,6 +2461,7 @@ function watchbar_copyUnreadStatusFromTo(old: Watchbar, newWatchbar: Watchbar) {
function makeStranger(store: Store): Myself {
const stranger = {
dbgSrc: '5BRCW27',
+ id: Pats.NoPatId,
isStranger: true,
trustLevel: TrustLevel.Stranger,
threatLevel: ThreatLevel.HopefullySafe,
diff --git a/client/app-slim/Server.ts b/client/app-slim/Server.ts
index ab7ed88806..8fd51148b3 100644
--- a/client/app-slim/Server.ts
+++ b/client/app-slim/Server.ts
@@ -28,14 +28,34 @@
namespace debiki2.Server {
//------------------------------------------------------------------------------
-const d: any = { i: debiki.internal };
-
const BadNameOrPasswordErrorCode = '_TyE403BPWD';
const NoPasswordErrorCode = '_TyMCHOOSEPWD';
const XsrfTokenHeaderName = 'X-XSRF-TOKEN'; // CLEAN_UP rename to X-Ty-Xsrf-Token
const SessionIdHeaderName = 'X-Ty-Sid';
const AvoidCookiesHeaderName = 'X-Ty-Avoid-Cookies';
+//const PersonaCookieName = "TyCoPersona";
+const PersonaHeaderName = 'X-Ty-Persona';
+
+
+// Mayble later. [remember_persona_mode]
+/*
+export function setpersonacookie(asPersona: Oneself | LazyCreatedAnon | N) {
+ // Or remember in local storage instead?
+ getSetCookie(PersonaCookieName, asPersona ? JSON.stringify(asPersona) : null);
+}
+
+export function getPersonaCookie(): Oneself | LazyCreatedAnon | NU {
+ const cookieVal: St | N = getCookie(PersonaCookieName);
+ if (!cookieVal) return undefined;
+ const persona = JSON.parse(cookieVal);
+ // @ifdef DEBUG
+ dieIf(!(persona as Oneself).self && !(persona as LazyCreatedAnon).anonStatus,
+ `Bad persona cookie value: [[ ${cookieVal} ]] [TyE320JVMR4]`);
+ // @endif
+ return persona;
+}
+*/
export function getPageId(): PageId | U { // move elsewhere?
return !isNoPage(eds.embeddedPageId) ? eds.embeddedPageId : // [4HKW28]
@@ -754,6 +774,11 @@ export function addAnyNoCookieHeaders(headers: { [headerName: string]: St }) {
const typs: PageSession = mainWin.typs;
const currentPageXsrfToken: St | U = typs.xsrfTokenIfNoCookies;
const currentPageSid: St | U = typs.weakSessionId;
+ const asPersona: WhichPersona | NU = mainWin.theStore && mainWin.theStore.me.usePersona;
+ const curPersonaOptions: PersonaOptions | U =
+ mainWin.theStore && mainWin.theStore.curPersonaOptions;
+ const indicatedPersona: PersonaMode | U =
+ mainWin.theStore && mainWin.theStore.indicatedPersona;
if (!win_canUseCookies(mainWin)) {
headers[AvoidCookiesHeaderName] = 'Avoid';
@@ -766,9 +791,61 @@ export function addAnyNoCookieHeaders(headers: { [headerName: string]: St }) {
if (currentPageSid) {
headers[SessionIdHeaderName] = currentPageSid;
}
+
+ // ----- Persona mode?
+
+ // Any Persona Mode is included in each request, so a cookie would have been a good idea
+ // — but cookies are often blocked, nowadays, if in an iframe (embedded comments). So,
+ // let's use a header instead.
+ const personaHeaderVal: St | U =
+ // If the user has choosen to use a persona, e.g. a pseudonym.
+ asPersona ? persModeToHeaderVal('choosen', asPersona) : (
+ // If the user is automatically anonymous, e.g. because of category settings,
+ // or because they replied anonymously before on the same page.
+ // For a server side safety check, so won't accidentally do sth as oneself.
+ // [persona_indicator_chk]
+ indicatedPersona ? persModeToHeaderVal(
+ 'indicated', indicatedPersona, curPersonaOptions.isAmbiguous) :
+ // No alias in use.
+ // Pat has not entered Anonymous or Self mode, and isn't anonymous by default.
+ undefined);
+
+ if (personaHeaderVal) {
+ headers[PersonaHeaderName] = personaHeaderVal;
+ }
}
+/// Won't stringify any not-needed fields in `mode`.
+/// Parsed by the server here: [parse_pers_mode_hdr].
+///
+/// Json in a header value is ok — all visible ascii chars are ok, see:
+/// https://www.rfc-editor.org/rfc/rfc7230#section-3.2
+///
+function persModeToHeaderVal(field: 'choosen' | 'indicated', mode: WhichPersona | PersonaMode,
+ ambiguous?: Bo): St {
+ //D_DIE_IF(isVal(mode.anonStatus) && !_.isNumber(mode.anonStatus), 'TyEANONSTATUSNAN');
+ const modeValue: WhichPersona =
+ mode.self ? { self : true } : (
+ mode.anonStatus ? { anonStatus: mode.anonStatus, lazyCreate: true } : (
+ // [pseudonyms_later]
+ 'TyEUNKINDPERS' as any));
+ const obj = {} as any;
+ obj[field] = modeValue;
+ if (ambiguous) obj.ambiguous = true;
+ return JSON.stringify(obj as PersonaHeaderVal);
+}
+
+
+type PersonaHeaderVal =
+ { choosen: PersonaHeaderValVal } |
+ { indicated: PersonaHeaderValVal; ambiguous?: true };
+
+type PersonaHeaderValVal =
+ { self: true } |
+ { anonStatus: AnonStatus, lazyCreate: true };
+
+
function appendE2eAndForbiddenPassword(url: string) {
let newUrl = url;
const e2eTestPassword = anyE2eTestPassword();
@@ -1543,21 +1620,21 @@ export function listDrafts(userId: UserId,
export function loadNotifications(userId: UserId, upToWhenMs: number,
- success: (notfs: Notification[]) => void, error: () => void) {
+ onOk: (notfs: Notification[]) => void, error: () => void) {
const query = '?userId=' + userId + '&upToWhenMs=' + upToWhenMs;
- get('/-/load-notifications' + query, success, error);
+ get('/-/load-notifications' + query, (resp: NotfSListResponse) => onOk(resp.notfs), error);
}
export function markNotfsRead() {
- postJsonSuccess('/-/mark-all-notfs-as-seen', (notfs) => {
+ postJsonSuccess('/-/mark-all-notfs-as-seen', (resp: NotfSListResponse) => {
// Should be the same as [7KABR20], server side.
const myselfPatch: MyselfPatch = {
numTalkToMeNotfs: 0,
numTalkToOthersNotfs: 0,
numOtherNotfs: 0,
thereAreMoreUnseenNotfs: false,
- notifications: notfs,
+ notifications: resp.notfs,
};
ReactActions.patchTheStore({ me: myselfPatch });
}, null, {});
@@ -1679,10 +1756,15 @@ export function loadForumCategoriesTopics(forumPageId: St, topicFilter: St,
// Change the reply
// 'users' field to 'usersBrief', no, 'patsBr'? 'Tn = Tiny, Br = Brief, Vb = Verbose? [.get_n_patch]
export function loadForumTopics(categoryId: CatId, orderOffset: OrderOffset,
- onOk: (resp: LoadTopicsResponse) => Vo) {
+ onOk: (resp: LoadTopicsResponse) => V) {
const url = '/-/list-topics?categoryId=' + categoryId + '&' +
ServerApi.makeForumTopicsQueryParams(orderOffset);
- getAndPatchStore(url, onOk); // [2WKB04R]
+ return get(url, function(resp: LoadTopicsResponse) {
+ // (Alternatively, the server could incl `listingCatId` in its response.)
+ const patch: StorePatch = { ...resp.storePatch, listingCatId: categoryId };
+ ReactActions.patchTheStore(patch); // [2WKB04R]
+ onOk(resp);
+ });
}
@@ -1807,14 +1889,7 @@ export function fetchLinkPreview(url: St, inline: Bo, /* later: curPageId: PageI
}
-export function saveVote(data: {
- pageId: PageId,
- postNr: PostNr,
- vote: PostVoteType,
- action: 'DeleteVote' | 'CreateVote',
- postNrsRead: PostNr[],
- doAsAnon?: MaybeAnon,
-}, onDone: (storePatch: StorePatch) => Vo) {
+export function saveVote(data: SaveVotePs, onDone: (storePatch: StorePatch) => V) {
// Specify altPageId and embeddingUrl, so any embedded page can be created lazily. [4AMJX7]
// @ifdef DEBUG
dieIf(data.pageId && data.pageId !== EmptyPageId && data.pageId !== getPageId(), 'TyE2ABKSY7');
@@ -2155,12 +2230,13 @@ export function hidePostInPage(postNr: number, hide: boolean, success: (postAfte
}
-export function deletePostInPage(postNr: number, repliesToo: boolean,
- success: (deletedPost) => void) {
- postJsonSuccess('/-/delete-post', success, {
+export function deletePostInPage(postNr: PostNr, repliesToo: Bo, doAsAnon: MaybeAnon | U,
+ onOk: (deletedPost) => V) {
+ postJsonSuccess('/-/delete-post', onOk, {
pageId: getPageId(),
postNr: postNr,
repliesToo: repliesToo,
+ doAsAnon,
});
}
@@ -2311,26 +2387,30 @@ export function loadPageJson(path: string, success: (response) => void) {
}
-export function acceptAnswer(postId: PostNr, doAsAnon: MaybeAnon,
+export function acceptAnswer(ps: { pageId: PageId, postId: PostId, doAsAnon: MaybeAnon },
onOk: (answeredAtMs: Nr) => V) {
- postJsonSuccess('/-/accept-answer', onOk, { pageId: getPageId(), postId, doAsAnon });
+ postJsonSuccess('/-/accept-answer', onOk, ps);
}
-export function unacceptAnswer(doAsAnon: MaybeAnon, onOk: () => V) {
- postJsonSuccess('/-/unaccept-answer', onOk, { pageId: getPageId(), doAsAnon });
+export function unacceptAnswer(ps: { pageId: PageId, doAsAnon: MaybeAnon }, onOk: () => V) {
+ postJsonSuccess('/-/unaccept-answer', onOk, ps);
}
-export function togglePageClosed(doAsAnon: MaybeAnon, onOk: (closedAtMs: Nr) => V) {
- postJsonSuccess('/-/toggle-page-closed', onOk, { pageId: getPageId(), doAsAnon });
+
+export function togglePageClosed(ps: { pageId: PageId, doAsAnon: MaybeAnon },
+ onOk: (closedAtMs: Nr) => V) {
+ postJsonSuccess('/-/toggle-page-closed', onOk, ps);
}
-export function deletePages(pageIds: PageId[], success: () => void) {
- postJsonSuccess('/-/delete-pages', success, { pageIds: pageIds });
+
+export function deletePages(ps: { pageIds: PageId[], doAsAnon: MaybeAnon }, onOk: () => V) {
+ postJsonSuccess('/-/delete-pages', onOk, ps);
}
-export function undeletePages(pageIds: PageId[], success: () => void) {
- postJsonSuccess('/-/undelete-pages', success, { pageIds: pageIds });
+
+export function undeletePages(ps: { pageIds: PageId[], doAsAnon: MaybeAnon }, onOk: () => V) {
+ postJsonSuccess('/-/undelete-pages', onOk, ps);
}
diff --git a/client/app-slim/avatar/avatar.ts b/client/app-slim/avatar/avatar.ts
index 1469e7a612..8fc62ebad7 100644
--- a/client/app-slim/avatar/avatar.ts
+++ b/client/app-slim/avatar/avatar.ts
@@ -37,22 +37,25 @@ export function resetAvatars() {
}
-export const Avatar = createComponent({
+export const Avatar = createFactory({
displayName: 'Avatar',
- onClick: function(event) {
+ onClick: function(event: MouseEvent) {
event.stopPropagation();
event.preventDefault();
- morebundle.openAboutUserDialog(this.props.user.id, event.target, this.props.title);
+ const props: AvatarProps = this.props;
+ morebundle.openAboutUserDialog(props.user.id, event.target, props.title);
},
tiny: function() {
- return !this.props.size || this.props.size === AvatarSize.Tiny;
+ const props: AvatarProps = this.props;
+ return !props.size || props.size === AvatarSize.Tiny;
},
makeTextAvatar: function() {
- const user: BriefUser = this.props.user;
- const hidden: boolean = this.props.hidden;
+ const props: AvatarProps = this.props;
+ const user: BriefUser = props.user;
+ const hidden = props.hidden;
let result = textAvatarsByUserId[user.id];
if (result)
return result;
@@ -157,9 +160,9 @@ export const Avatar = createComponent({
},
render: function() {
- const props = this.props;
- const user: BriefUser | MemberInclDetails = this.props.user;
- const ignoreClicks = this.props.ignoreClicks ||
+ const props: AvatarProps = this.props;
+ const user: BriefUser | MemberInclDetails = props.user;
+ const ignoreClicks = props.ignoreClicks ||
// The user is unknow when rendering the author avatar, in
// the new reply preview, if we haven't logged in. [305KGWGH2]
user.id === UnknownUserId;
@@ -179,16 +182,16 @@ export const Avatar = createComponent({
if (largestPicPath) {
// If we don't know the hash path to the avatar of the requested size, then use another size.
let picPath;
- if (this.props.size === AvatarSize.Medium) {
+ if (props.size === AvatarSize.Medium) {
picPath = largestPicPath;
}
- else if (this.props.size === AvatarSize.Small) {
+ else if (props.size === AvatarSize.Small) {
picPath = smlPath || tnyPath || medPath;
}
else {
picPath = tnyPath || smlPath || medPath;
}
- const origins: Origins = this.props.origins;
+ const origins: Origins = props.origins;
content = r.img({ src: linkToUpload(origins, picPath) });
}
else {
@@ -200,8 +203,8 @@ export const Avatar = createComponent({
}
}
let title = user.username || user.fullName;
- if (this.props.title) {
- title += ' — ' + this.props.title;
+ if (props.title) {
+ title += ' — ' + props.title;
}
if (props.showIsMine) {
@@ -215,7 +218,7 @@ export const Avatar = createComponent({
const elemName = ignoreClicks ? 'span' : 'a';
const elemFn = r[elemName];
const href = ignoreClicks ? null : linkToUserProfilePage(user);
- const onClick = ignoreClicks || this.props.clickOpensUserProfilePage ?
+ const onClick = ignoreClicks || props.clickOpensUserProfilePage ?
null : this.onClick;
return (
diff --git a/client/app-slim/forum/forum.ts b/client/app-slim/forum/forum.ts
index 5d99ae1137..8c9d035b62 100644
--- a/client/app-slim/forum/forum.ts
+++ b/client/app-slim/forum/forum.ts
@@ -988,6 +988,7 @@ const LoadAndListTopics = createFactory({
// can continue reading at the top of the newly loaded topics.
$byId('esPageColumn').classList.add('s_NoScrlAncr');
+ // This updates `store.listingCatId`.
Server.loadForumTopics(categoryId, orderOffset, (response: LoadTopicsResponse) => { // (4AB2D)
if (this.isGone) return;
let topics: any = isNewView ? [] : (this.state.topics || []);
diff --git a/client/app-slim/model.ts b/client/app-slim/model.ts
index 8c26407d6c..291ba02611 100644
--- a/client/app-slim/model.ts
+++ b/client/app-slim/model.ts
@@ -256,6 +256,16 @@ interface Vote {
}
+interface SaveVotePs {
+ pageId: PageId,
+ postNr: PostNr,
+ vote: PostVoteType,
+ action: 'DeleteVote' | 'CreateVote',
+ postNrsRead: PostNr[],
+ doAsAnon?: MaybeAnon,
+}
+
+
interface DraftLocator {
draftType: DraftType;
categoryId?: number;
@@ -283,7 +293,7 @@ interface DraftDeletor {
interface Draft {
byUserId: UserId;
- doAsAnon?: MaybeAnon; // not yet impl [doAsAnon_draft]
+ doAsAnon?: MaybeAnon;
draftNr: DraftNr;
forWhat: DraftLocator;
createdAt: WhenMs;
@@ -363,11 +373,17 @@ interface ShowPostOpts extends ScrollIntoViewOpts {
interface Post {
// Client side only ------
+ // [drafts_as_posts] Later, the drafts3 table will get deleted, and drafts moved to
+ // posts3 / nodes_t instead. These fields are in fact for drafts / previews:
+
// If this post / these changes don't yet exist — it's a preview.
isPreview?: boolean;
// Is a number if the draft has been saved server side (then the server has
// assigned it a number).
isForDraftNr?: DraftNr | true;
+ // If this post is a draft and we need to lazy-create an anonym, this tells us what type
+ // of anonym: temporarily or permanently anonymous (its AnonStatus).
+ doAsAnon?: MaybeAnon,
// If we're editing this post right now.
isEditing?: boolean;
// -----------------------
@@ -485,6 +501,10 @@ interface MyPageData {
// For the current page only.
marksByPostId: { [postId: number]: any }; // sleeping BUG: probably using with Nr (although the name implies ID), but should be ID
+
+ // Needed for deriving Store.curPersonaOptions.
+ // Currently derived client side. Later: The server can include in response. [fetch_alias]
+ myPersonas?: MyPersonasThisPage;
}
@@ -505,13 +525,16 @@ interface OwnPageNotfPrefs { // RENAME to MembersPageNotfPrefs?
// Extend Pat, set id to a new StrangerId if not logged in?
type Myself = Me; // renaming to Me
-interface Me extends OwnPageNotfPrefs { // + extends Pat?
+interface Me extends OwnPageNotfPrefs, Pat {
dbgSrc?: string;
// This is not the whole session id — it's the first 16 chars only [sid_part1];
// the remaining parts have (a lot) more entropy than necessary.
mySidPart1?: St | N;
- id?: UserId;
- //useAlias?: Pat; // maybe? [alias_mode] and this.id is one's true id
+ // Is Pats.NoPatId (0, zero) for strangers, so this Me interface fullfills the Pat interface.
+ id: PatId;
+
+ usePersona?: Oneself | LazyCreatedAnon | N;
+
isStranger?: Bo;
// missing?: isGuest?: Bo
isGroup?: boolean; // currently always undefined (i.e. false)
@@ -706,6 +729,7 @@ interface HelpMessage {
version: number;
content: any;
defaultHide?: boolean;
+ closeOnClickOutside?: false; // default true
doAfter?: () => void;
type?: number;
className?: string;
@@ -1195,6 +1219,8 @@ interface SessWinStore {
/// Can be 1) the main ('top') browser win (incl topbar, editor, sidebars etc), or
/// 2) a copy of the store in an embedded comments iframe. [many_embcom_iframes]
///
+/// Maybe should [break_out_clone_store_fn]?
+///
interface DiscStore extends SessWinStore {
currentPage?: Page;
currentPageId?: PageId;
@@ -1202,6 +1228,11 @@ interface DiscStore extends SessWinStore {
curCatsById: { [catId: CatId]: Cat };
usersByIdBrief: { [userId: number]: Pat }; // = PatsById
pagesById: { [pageId: string]: Page };
+
+ // Derived client side from: MyPageData.myPersonas and Me.usePersona.
+ curPersonaOptions?: PersonaOptions // ? move to SessWinStore ?
+ curDiscProps?: DiscPropsDerived
+ indicatedPersona?: PersonaMode // ? move to SessWinStore ?
}
@@ -1235,6 +1266,18 @@ interface Store extends Origins, DiscStore, PartialEditorStoreState {
publicCategories: Category[]; // RENAME [concice_is_nice] pubCats
newCategorySlug: string; // for temporarily highlighting a newly created category
topics?: Topic[];
+
+ // Says which category we're listing topics in, if we're on a forum homepage (or
+ // other topic index page). Could be the forum root category, or a base category,
+ // or sub category, or sub sub ...).
+ //
+ // This is good to know, when composing a new forum topic, because new topics inherit
+ // some properties from the currently listed category. For example, if you're on a
+ // forum homepage, and view an anonymous-by-default category and click Create Topic,
+ // the new topic will be anonymous.
+ //
+ listingCatId?: CatId;
+
user: Myself; // try to remove, use 'me' instead:
me: Myself;
userSpecificDataAdded?: boolean; // is always false, server side
@@ -1356,6 +1399,47 @@ type PropsFromRefs = {
// RENAME to DiscLayoutDerived? There's an interface Layout_not_in_use too (below) merging all layouts.
+/// Discussion properties, intherited from the page, ancestor categories, site defaults,
+/// built-in defaults.
+///
+/// The `from` fields says from where each property got inherited, so admins can know what
+/// they might want to change, if sth isn't to their satisfaction.
+///
+/// Example: {
+/// comtOrder: 3,
+/// comtNesting: -1,
+/// comtsStartHidden :2,
+/// comtsStartAnon: 3, <—— modified, and
+/// opStartsAnon:2,
+/// newAnonStatus: 65535, <—— modified ...
+/// pseudonymsAllowed: 2,
+/// from: {
+/// comtsStartAnon: {
+/// // ... in this category:
+/// id: 5,
+/// parentId: 1,
+/// name: "AA",
+/// ...
+/// comtsStartAnon: 3, <—— category setting changed
+/// newAnonStatus: 65535,
+/// ...
+/// },
+/// newAnonStatus: {
+/// // Same category, again: (just an example)
+/// id:5,
+/// parentId: 1,
+/// ...
+/// comtsStartAnon: 3,
+/// newAnonStatus":65535, <——
+/// ...
+/// },
+/// comtOrder: "BuiltIn",
+/// comtNesting: "BuiltIn",
+/// comtsStartHidden: "BuiltIn",
+/// opStartsAnon: "BuiltIn",
+/// pseudonymsAllowed: "BuiltIn",
+/// }
+/// }'
interface DiscPropsDerived extends DiscPropsBase {
from: DiscPropsComesFrom;
}
@@ -1528,52 +1612,21 @@ type PpsById = { [ppId: number]: Participant }; // RENAME to PatsById
interface Anonym extends GuestOrAnon {
- isAnon: true;
+ anonStatus: AnonStatus
+ //anonOnPageId: PageId; // not yet incl server side
+ isAnon: true; // REMOVE
isGuest?: false; // = !isAuthenticated — no! BUG RISK ensure ~isGuest isn't relied on
// anywhere, to "know" it's a user / group
}
interface KnownAnonym extends Anonym {
- isAnon: true;
anonForId: PatId;
- anonStatus: AnonStatus;
- anonOnPageId: PageId;
-}
-
-/// `false` means don't-do-anonymously, use one's real account instead.
-type MaybeAnon = WhichAnon | false;
-
-// For choosing an anonym. Maybe rename to ChooseAnon? Or ChoosenAnon / SelectedAnon?
-interface WhichAnon {
- sameAnonId?: PatId; // Either this ...
- anonStatus?: AnonStatus; // and this,
- newAnonStatus?: AnonStatus; // ... or this.
}
-interface SameAnon extends WhichAnon {
- sameAnonId: PatId;
- anonStatus: AnonStatus.IsAnonOnlySelfCanDeanon | AnonStatus.IsAnonCanAutoDeanon;
- newAnonStatus?: U;
-}
-interface NewAnon extends WhichAnon {
- sameAnonId?: U;
- newAnonStatus: AnonStatus.IsAnonOnlySelfCanDeanon | AnonStatus.IsAnonCanAutoDeanon;
-}
-
-/* Confusing! type MaybeAnon above, is better?
-interface NotAnon extends WhichAnon {
- sameAnonId?: U;
- newAnonStatus?: U;
- anonStatus: AnonStatus.NotAnon;
-} */
-
-interface MyPatsOnPage {
- // Each item can be an anonym or pseudonym of pat, or pat henself. No duplicates.
- sameThread: Pat[];
- outsideThread: Pat[]
- byId: { [patId: PatId]: Pat | SameAnon }
+interface FutureAnon extends KnownAnonym {
+ id: Pats.FutureAnonId,
}
@@ -1711,6 +1764,204 @@ interface PatVvb extends UserInclDetailsWithStats {
}
type UserDetailsStatsGroups = PatVvb; // old name
+
+
+// =========================================================================
+// Personas
+// =========================================================================
+
+
+/// Anonyms, pseudonyms, and oneself, are personas.
+///
+interface WhichPersona {
+ self?: Bo
+ anonStatus?: AnonStatus;
+}
+
+
+interface Oneself extends WhichPersona {
+ self: true
+ anonStatus?: U;
+};
+
+
+/// Anonyms and pseudonyms are aliases. (But oneself is not an alias.)
+///
+interface AsAlias extends WhichPersona {
+ self?: false
+}
+
+
+interface SamePseudonym extends AsAlias {
+ pseudonymId: UserId;
+ anonStatus?: U;
+}
+
+
+/// `false` means don't-do-anonymously, use one's real account instead.
+/// REFACTOR: Use `Oneself` instead of `false`? Better: just use type WhichPersona?
+/// Here: [oneself_0_false] and at maaany other places.
+type MaybeAnon = WhichAnon | false;
+
+/// Which anonym to use (if any) when doing something, e.g. posting a comment.
+/// See [one_anon_per_page] in tyworld.adoc.
+///
+interface WhichAnon extends AsAlias {
+ anonStatus: AnonStatus
+ sameAnonId?: PatId // either this
+ lazyCreate?: Bo // or this
+ createNew_tst?: Bo // or this
+}
+
+/// The anonym with id `sameAnonId` should be looked up server side, and reused.
+///
+interface SameAnon extends WhichAnon {
+ sameAnonId: PatId // RENAME to just `id`?
+ lazyCreate?: false
+ createNew_tst?: false
+}
+
+/// If there's an anonym with the same `anonStatus`, on the same page, for the
+/// same user, then, that anonym should be reused (the server looks in the database
+/// to find out, see `SiteTransaction.loadAnyAnon()`). Otherwise, a new created.
+///
+interface LazyCreatedAnon extends WhichAnon {
+ sameAnonId?: U
+ lazyCreate: true
+ createNew_tst?: U
+}
+
+/// Always creates a new anonym, even if one exists with the same status.
+/// Not currently in use. [one_anon_per_page]
+interface NewAnon extends WhichAnon {
+ sameAnonId?: U
+ lazyCreate?: false
+ createNew_tst: true
+}
+
+
+interface MyPersonasThisPage {
+ // Each item can be an anonym or pseudonym of pat, or pat hanself. No duplicates.
+ sameThread: Pat[];
+ // Pats in `sameThread` are not also in `outsideThread` (not needed).
+ outsideThread: Pat[]
+ byId: { [patId: PatId]: Pat }
+}
+
+
+/// Which anonyms and pseudonyms, and oneself, the user can choose among when
+/// posting something or voting, or editing. Is just one item, if anonyms and pseudonyms
+/// haven't been enabled: the user hanself.
+///
+interface PersonaOptions {
+ // If we should ask the user which persona they want to use, before
+ // doing anything (e.g. voting or commenting). So they don't accidentally
+ // use the wrong persona.
+ isAmbiguous: Bo
+
+ // If has commented or voted or sth as oneself, on the current page.
+ hasBeenSelf?: Bo
+ // If has commented or voted or sth anonymously, on the current page.
+ // Could split into two: `hasBeenTempAnon` and `hasBeenPermAnon`, [dif_anon_status]
+ // (or even some kind of Set, if there'll be many anon status types).
+ hasBeenAnon?: Bo
+ hasBeenPseudo?: Bo
+
+ // Never empty — would be oneself, if nothing else.
+ optsList: PersonaOption[]
+}
+
+
+interface PersonaOption {
+ alias: Pat /*me*/ | KnownAnonym | FutureAnon // | Pseudonym
+ // RENAME to whichPersonaId?
+ doAs: MaybeAnon;
+ isBestGuess?: Bo // then should be first in PersonaOptions.list.
+ inSameThread?: Bo
+ onSamePage?: Bo
+ // If is from any Persona Mode, e.g. Anonymous Mode.
+ isFromMode?: Bo
+ // If is from the category or page properties.
+ isFromProps?: Bo
+ // If recommended by the cat or page properties.
+ isRecommended?: Bo
+ // If persona not allowed, e.g. anon comments used to be enabled and we posted an
+ // anonymous comment, but anon comments then got disabled.
+ isNotAllowed?: Bo
+ isSelf?: Bo
+}
+
+// Exactly one set.
+type PersonaMode = { pat?: Pat, self?: true, anonStatus?: AnonStatus };
+
+
+/// The persona the user has choosen to use, and the ones han can choose among (can
+/// depend on the current page, since anonyms are per page).
+///
+interface DoAsAndOpts {
+ doAsAnon: MaybeAnon
+ /// Which participants the current usr can choose among: hanself (`false`),
+ /// hans anonyms and pseudonyms.
+ /// CLEAN_UP: Is this field really needed? Can't `PersonaOptions.optsList` be used
+ /// instead somehow? (`optsList.map(_.doAs)`) should be the same as `myAliasOpts`.
+ myAliasOpts: MaybeAnon[] // [ali_opts_only_needed_here]
+}
+
+
+/// For choosing which persona to use, if editing / altering a comment or a page.
+///
+interface ChooseEditorPersonaPs {
+ // [Origins_needed_for_avatar_images].
+ store: DiscStore & Origins
+ postNr?: PostNr
+ atRect: Rect
+ draft?: Draft
+ isInstantAction?: true
+}
+
+
+/// For choosing which persona to use, when posting comments or pages, or voting.
+///
+interface ChoosePosterPersonaPs {
+ atRect?: Rect
+ me: Me
+ // [Origins_needed_for_avatar_images] — so can show one's pseudonyms' avatars (which
+ // might be images hosted by a CDN — origin needed) if listing pseudonyms (to select one).
+ origins: Origins
+ // Might be different from the current store — if pat is composing a new page in
+ // a category with different settings (`DiscPropsDerived`) than the forum root category.
+ // CLEAN_UP: `store: DiscStore & Origins` instead, like in ChooseEditorPersonaPs?
+ discStore: DiscStore
+ postNr?: PostNr
+ draft?: Draft
+}
+
+
+/// A dropdown for choosing which persona to use (e.g. if posting anonymous comments).
+///
+/// CLEAN_UP: Would it be possible/better to use ChooseEditorPersonaPs or
+/// ChoosePosterPersonaPs instead?
+///
+interface ChoosePersonaDlgPs {
+ atRect: Rect;
+ open?: Bo;
+ pat?: Pat;
+ me: Me,
+ // Any currently selected alias.
+ curAnon?: MaybeAnon;
+ // Which anonyms and pseudonyms to list in the choose-alias menu. (The options.)
+ myAliasOpts?: MaybeAnon[];
+ discProps: DiscPropsDerived;
+ saveFn: (_: MaybeAnon) => V;
+}
+
+
+
+// =========================================================================
+//
+// =========================================================================
+
+
interface CreateUserParams {
idpName?: St;
idpHasVerifiedEmail?: Bo;
@@ -1961,6 +2212,8 @@ interface StorePatch
publicCategories?: Category[];
restrictedCategories?: Category[];
+ listingCatId?: CatId;
+
pageVersionsByPageId?: { [pageId: string]: PageVersion };
postsByPageId?: { [pageId: string]: Post[] };
@@ -2300,6 +2553,20 @@ interface IdentityProviderSecretConf extends IdentityProviderPubFields {
// =========================================================================
+/// For rendering a (A) text/image circle with one's first letter or tiny profile pic.
+interface AvatarProps {
+ user: BriefUser
+ origins: Origins
+ size?: AvatarSize
+ title?: any
+ hidden?: Bo
+ showIsMine?: Bo
+ ignoreClicks?: Bo
+ clickOpensUserProfilePage?: Bo
+ key?: St | Nr;
+}
+
+
/// For rendering a new page.
interface ShowNewPageParams {
newPage: Page; // | AutoPage;
@@ -2356,40 +2623,6 @@ interface AuthnDlgIf {
}
-/// If clicking e.g. Like, one might need to choose if the like vote should
-/// be anonymous or not.
-interface MaybeChooseAnonPs {
- store: DiscStore
- discProps?: DiscPropsDerived
- postNr?: PostNr
- atRect?: Rect
-}
-
-interface ChoosenAnon {
- doAsAnon: MaybeAnon
- /// Which participants the current usr can choose among: hanself (`false`),
- /// hans anonyms and pseudonyms.
- myAliasOpts: MaybeAnon[]
-}
-
-
-/// A dropdown for choosing which anonym to use (e.g. if posting anonymous comments).
-/// DlgPs = dialog parameters, hmm.
-///
-interface ChooseAnonDlgPs {
- atRect: Rect;
- open?: Bo;
- pat?: Pat;
- me: Me,
- // Any currently selected alias.
- curAnon?: MaybeAnon;
- // Which anonyms and pseudonyms to list in the choose-alias menu. (The options.)
- myAliasOpts?: MaybeAnon[];
- discProps: DiscPropsDerived;
- saveFn: (_: MaybeAnon) => V;
-}
-
-
/// For rendering category trees.
interface CatsTree {
rootCats: CatsTreeCat[];
@@ -2512,6 +2745,7 @@ interface PatsToAddRemove {
interface ExplainingTitleText {
iconUrl?: St;
+ img?: any; // RElm;
title: any; // St | RElm; —> compil err in server & blog comments bundles
text?: any; // St | RElm;
key?: any;
@@ -2542,10 +2776,20 @@ interface ExplainingListItemProps extends ExplainingTitleText {
}
+interface SimpleProxyDiagParams extends ProxyDiagParams {
+ body: any // RElm
+ primaryButtonTitle?: any // RElm
+ secondaryButonTitle?: any // RElm
+ closeButtonTitle?: any;
+ onPrimaryClick?: () => V
+ onCloseOk?: (whichBtn: Nr) => V;
+}
+
+
interface ProxyDiagParams extends SharedDiagParams {
atRect: Rect; // required (optional in SharedDiagParams)
flavor?: DiagFlavor;
- showCloseButton?: Bo; // default true
+ showCloseButton?: Bo; // default true, RENAME to showCloseCross? (upper right corner)
dialogClassName?: St;
contentClassName?: St;
closeOnButtonClick?: Bo; // default false
@@ -2559,8 +2803,6 @@ interface DropdownProps extends SharedDiagParams {
show: Bo;
showCloseButton?: true;
//bottomCloseButton?: true; — not yet impl
- onHide: () => Vo;
- closeOnClickOutside?: false; // default true
onContentClick?: (event: MouseEvent) => Vo;
atX?: Nr;
atY?: Nr;
@@ -2573,6 +2815,8 @@ interface SharedDiagParams {
atRect?: Rect;
pullLeft?: Bo;
allowFullWidth?: Bo;
+ closeOnClickOutside?: false; // default true
+ onHide?: () => V;
}
@@ -2975,6 +3219,11 @@ interface TerminateSessionsResponse {
}
+interface NotfSListResponse {
+ notfs: Notification[]
+}
+
+
// COULD also load info about whether the user may apply and approve the edits.
interface LoadDraftAndTextResponse {
pageId: PageId;
diff --git a/client/app-slim/more-bundle-not-yet-loaded.ts b/client/app-slim/more-bundle-not-yet-loaded.ts
index 818514ae8d..47eab0e8b4 100644
--- a/client/app-slim/more-bundle-not-yet-loaded.ts
+++ b/client/app-slim/more-bundle-not-yet-loaded.ts
@@ -58,19 +58,26 @@ export function showCreateUserDialog(params: CreateUserParams) {
}
-export function maybeChooseModAlias(ps: MaybeChooseAnonPs, then?: (_: ChoosenAnon) => V) {
+export function chooseEditorPersona(ps: ChooseEditorPersonaPs, then?: (_: DoAsAndOpts) => V) {
Server.loadMoreScriptsBundle(() => {
- anon.maybeChooseModAlias(ps, then);
+ persona.chooseEditorPersona(ps, then);
});
}
-export function maybeChooseAnon(ps: MaybeChooseAnonPs, then: (_: ChoosenAnon) => V) {
+export function choosePosterPersona(ps: ChoosePosterPersonaPs,
+ then: (_: DoAsAndOpts | 'CANCEL') => V) {
Server.loadMoreScriptsBundle(() => {
- anon.maybeChooseAnon(ps, then);
+ persona.choosePosterPersona(ps, then);
});
}
+export function openPersonaInfoDiag(ps: { atRect: Rect, isSectionPage: Bo,
+ me: Me, personaOpts: PersonaOptions, discProps: DiscPropsDerived }): V {
+ Server.loadMoreScriptsBundle(() => {
+ persona.openPersonaInfoDiag(ps);
+ });
+}
export function openAboutUserDialog(who: number | string | BriefUser, at, extraInfo?: string) {
Server.loadMoreScriptsBundle(() => {
@@ -99,9 +106,9 @@ export function openAddPeopleDialog(ps: { curPatIds?: PatId[], curPats?: Pat[],
}
-export function openDeletePostDialog(post: Post, at: Rect) {
+export function openDeletePostDialog(ps: { post: Post, at: Rect, doAsAnon?: MaybeAnon }) {
Server.loadMoreScriptsBundle(() => {
- debiki2.pagedialogs.openDeletePostDialog(post, at);
+ debiki2.pagedialogs.openDeletePostDialog(ps);
});
}
diff --git a/client/app-slim/oop-methods.ts b/client/app-slim/oop-methods.ts
index 9b7c21b2b2..769107a004 100644
--- a/client/app-slim/oop-methods.ts
+++ b/client/app-slim/oop-methods.ts
@@ -198,6 +198,7 @@ export function pageNotfPrefTarget_findEffPref(
// unapproved replies (and edit them).
// The real fix would be to include a `MyPageData.iHaveReplied` field server side?
// Like so: ownPrefs.myDataByPageId[target.pageId].iHaveReplied ?
+ // That's also needed for finding out which alias (if any) to use. [fetch_alias]
const anyReplyByPat =
_.find(store.pagesById[target.pageId]?.postsByNr, // [On1]
(p: Post) => p.authorId === store.me.id);
@@ -688,9 +689,13 @@ export function member_isBuiltIn(member: Member): Bo {
// Dupl code [disp_name]
-export function pat_name(pat: Me | Pat): St {
+export function pat_name(patOrLazyAnon: Me | Pat | LazyCreatedAnon | NewAnon): St {
+ if (patOrLazyAnon.anonStatus)
+ return anonStatus_toStr(patOrLazyAnon.anonStatus); // [anon_2_str]
+
// Or prioritize username? Did, in the annon posts branch:
// if (pat.username) return '@' + pat.username;
+ const pat = patOrLazyAnon as Pat;
return pat.fullName || (pat.username ? '@' + pat.username : "_no_name_");
}
@@ -766,6 +771,60 @@ export function pat_mayEditTags(me: Me, ps: { forPost?: Post, forPat?: Pat,
+// Anonyms
+//----------------------------------
+
+
+export function anon_create(ps: { anonForId: UserId, anonStatus: AnonStatus,
+ isMe?: true }): FutureAnon {
+ const fullName = anonStatus_toStr(ps.anonStatus) + (ps.isMe ? " (you)" : ''); // I18N
+ return {
+ id: Pats.FutureAnonId,
+ anonForId: ps.anonForId,
+ fullName,
+ isAnon: true,
+ anonStatus: ps.anonStatus,
+ } satisfies FutureAnon;
+}
+
+
+export function anonStatus_toStr(anonStatus: AnonStatus, verb: Verbosity = Verbosity.Brief): St {
+ // [dif_anon_status]
+ if (anonStatus === AnonStatus.IsAnonCanAutoDeanon)
+ return verb >= Verbosity.Full ? "Temporarily Anonymous" : ( // I18N [anon_2_str]
+ verb >= Verbosity.Brief ? "Temp Anonymous" : (
+ verb >= Verbosity.Terse ? "Temp Anon" :
+ "Temp")); // Verbosity.VeryTerse
+
+ let prefix = '';
+ // @ifdef DEBUG
+ prefix = "P"; // for "Permanently". Nice in dev mode.
+ // @endif
+ if (anonStatus === AnonStatus.IsAnonOnlySelfCanDeanon)
+ return prefix + (verb >= Verbosity.Brief ? "Anonymous" : ( // I18N
+ verb >= Verbosity.Terse ? "Anonym" :
+ "Anon")); // Verbosity.VeryTerse
+
+ // Unknown status.
+ return `TyEUNKANSTA-${anonStatus}`;
+}
+
+
+// Persona mode
+//----------------------------------
+
+export function persMode_toStr(mode: PersonaMode, verb: Verbosity): St {
+ return mode.pat ?
+ // This might be pretty long! Not Verbosity.Brief at all, depending
+ // on the name. [pseudonyms_later]
+ mode.pat.username || mode.pat.fullName : (
+ mode.self ? (verb <= Verbosity.Terse ? "Self" : "Yourself") : ( // I18N
+ mode.anonStatus ? anonStatus_toStr(mode.anonStatus, verb) :
+ // Unknown mode.
+ "TyEUNKPERMODE"));
+}
+
+
// Settings
//----------------------------------
@@ -1300,7 +1359,7 @@ export function store_makeNewPostPreviewPatch(store: Store, page: Page,
// make an anonym with '?' as sequence number appear.
const authorId = doAsAnon ? doAsAnon.sameAnonId || Pats.FutureAnonId : store.me.id;
const previewPost = store_makePreviewPost({
- authorId, parentPostNr, safePreviewHtml, newPostType, isEditing: true });
+ authorId, doAsAnon, parentPostNr, safePreviewHtml, newPostType, isEditing: true });
return page_makePostPatch(page, previewPost);
}
@@ -1353,8 +1412,8 @@ export function store_makePostForDraft(authorId: PatId, draft: Draft): Post | Nl
// For now, use the CommonMark source instead.
const previewPost = store_makePreviewPost({
- authorId, parentPostNr, unsafeSource: draft.text, newPostType: postType,
- isForDraftNr: draft.draftNr || true });
+ authorId, doAsAnon: draft.doAsAnon, parentPostNr, unsafeSource: draft.text,
+ newPostType: postType, isForDraftNr: draft.draftNr || true });
return previewPost;
}
@@ -1379,6 +1438,7 @@ export function post_makePreviewIdNr(parentNr: PostNr, newPostType: PostType): P
interface MakePreviewParams {
authorId: PatId;
+ doAsAnon?: MaybeAnon;
parentPostNr?: PostNr;
safePreviewHtml?: string;
unsafeSource?: string;
@@ -1391,7 +1451,7 @@ interface MakePreviewParams {
function store_makePreviewPost({
- authorId, parentPostNr, safePreviewHtml, unsafeSource,
+ authorId, doAsAnon, parentPostNr, safePreviewHtml, unsafeSource,
newPostType, isForDraftNr, isEditing }: MakePreviewParams): Post {
dieIf(!newPostType, "Don't use for edit previews [TyE4903KS]");
@@ -1403,6 +1463,7 @@ function store_makePreviewPost({
const previewPost: Post = {
isPreview: true,
isForDraftNr,
+ doAsAnon,
isEditing,
uniqueId: previewPostIdNr,
@@ -1883,12 +1944,6 @@ function deriveLayoutImpl(page: PageDiscPropsSource, cat: Cat, store: DiscStore,
}
-export function page_authorId(page: Page): PatId | U {
- const origPost = page.postsByNr[BodyNr];
- return origPost && origPost.authorId;
-}
-
-
export function page_isClosedUnfinished(page: Page | Topic): Bo {
return page_isClosed(page) && !page_isSolved(page) && !page_isDone(page);
}
@@ -2032,6 +2087,7 @@ export function page_canChangeCategory(page: Page): boolean {
export function page_mostRecentPostNr(page: Page): number {
// BUG not urgent. COULD incl the max post nr in Page, so even if not yet loaded,
// we'll know its nr, and can load and scroll to it, from doUrlFragmentAction().
+ // Related to: [fetch_alias]
let maxNr = -1;
_.values(page.postsByNr).forEach((post: Post) => { // COULD use _.reduce instead
maxNr = Math.max(post.nr, maxNr);
diff --git a/client/app-slim/page/chat.ts b/client/app-slim/page/chat.ts
index 7cb316ac81..68f05a035e 100644
--- a/client/app-slim/page/chat.ts
+++ b/client/app-slim/page/chat.ts
@@ -176,13 +176,15 @@ const ChatMessage = createComponent({
edit: function() {
this.setState({ isEditing: true });
const post: Post = this.props.post;
+ // Later: Pass alias, if any. [anon_chats]
editor.openToEditPostNr(post.nr, (wasSaved, text) => {
this.setState({ isEditing: false });
});
},
- delete_: function(event) {
- morebundle.openDeletePostDialog(this.props.post, cloneEventTargetRect(event));
+ delete_: function(event: MouseEvent) {
+ // Later: [anon_chats].
+ morebundle.openDeletePostDialog({ post: this.props.post, at: cloneEventTargetRect(event) });
},
render: function() {
diff --git a/client/app-slim/page/discussion.ts b/client/app-slim/page/discussion.ts
index 3f4c742064..cbd54019b7 100644
--- a/client/app-slim/page/discussion.ts
+++ b/client/app-slim/page/discussion.ts
@@ -360,9 +360,12 @@ export const Title = createComponent({
return { editingPageId: null };
},
- editTitle: function() {
+ editTitle: function(event: MouseEvent) {
const store: Store = this.props.store;
- this.setState({ editingPageId: store.currentPageId });
+ const atRect = cloneEventTargetRect(event);
+ morebundle.chooseEditorPersona({ store, atRect, postNr: TitleNr }, doAsOpts => {
+ this.setState({ editingPageId: store.currentPageId, doAsOpts });
+ });
},
closeEditor: function() {
@@ -428,6 +431,7 @@ export const Title = createComponent({
if (this.state.editingPageId) {
const editorProps = _.clone(this.props);
editorProps.closeEditor = this.closeEditor;
+ editorProps.doAsOpts = this.state.doAsOpts;
contents = morebundle.TitleEditor(editorProps);
}
else {
diff --git a/client/app-slim/page/post-actions.ts b/client/app-slim/page/post-actions.ts
index 96518dba6b..6cdd958d49 100644
--- a/client/app-slim/page/post-actions.ts
+++ b/client/app-slim/page/post-actions.ts
@@ -145,16 +145,23 @@ function makeReplyBtnTitle(store: Store, post: Post) {
export const PostActions = createComponent({
displayName: 'PostActions',
- onAcceptAnswerClick: function() {
- morebundle.maybeChooseModAlias({ store: this.props.store, atRect: undefined },
- (choices: ChoosenAnon) => {
- ReactActions.acceptAnswer(this.props.post.uniqueId, choices.doAsAnon);
+ onAcceptAnswerClick: function(event: MouseEvent) {
+ const atRect: Rect = cloneEventTargetRect(event);
+ // A [pick_persona_click_handler] could save a few lines of code?
+ morebundle.chooseEditorPersona({ store: this.props.store, atRect, isInstantAction: true },
+ (doAsOpts: DoAsAndOpts) => {
+ const pageId = Server.getPageId();
+ const postId = this.props.post.uniqueId;
+ ReactActions.acceptAnswer({ pageId, postId, doAsAnon: doAsOpts.doAsAnon });
});
},
- onUnacceptAnswerClick: function() {
- morebundle.maybeChooseModAlias({ store: this.props.store, atRect: undefined },
- (choices: ChoosenAnon) => {
- ReactActions.unacceptAnswer(choices.doAsAnon);
+
+ onUnacceptAnswerClick: function(event: MouseEvent) {
+ const atRect: Rect = cloneEventTargetRect(event);
+ morebundle.chooseEditorPersona({ store: this.props.store, atRect, isInstantAction: true },
+ (doAsOpts: DoAsAndOpts) => {
+ const pageId = Server.getPageId();
+ ReactActions.unacceptAnswer({ pageId, doAsAnon: doAsOpts.doAsAnon });
});
},
@@ -557,8 +564,8 @@ const MoreVotesDropdownModal = createComponent({
const atRect = cloneEventTargetRect(event);
const post: Post = this.state.post;
loginIfNeededThen(LoginReason.LoginToDisagree, post.nr, () => {
- toggleVote(this.state.store, post, PostVoteType.Disagree, !this.hasVoted(PostVoteType.Disagree), atRect);
- this.closeSoon();
+ toggleVote(this.state.store, post, PostVoteType.Disagree, !this.hasVoted(PostVoteType.Disagree),
+ atRect, this.closeSoon);
});
},
onBuryClick: function(event: MouseEvent) {
@@ -566,16 +573,16 @@ const MoreVotesDropdownModal = createComponent({
const post: Post = this.state.post;
// Not visible unless logged in.
// [anon_mods]
- toggleVote(this.state.store, post, PostVoteType.Bury, !this.hasVoted(PostVoteType.Bury), atRect);
- this.closeSoon();
+ toggleVote(this.state.store, post, PostVoteType.Bury, !this.hasVoted(PostVoteType.Bury),
+ atRect, this.closeSoon);
},
onUnwantedClick: function(event: MouseEvent) {
const atRect = cloneEventTargetRect(event);
const post: Post = this.state.post;
// Not visible unless logged in.
// [anon_mods]
- toggleVote(this.state.store, post, PostVoteType.Unwanted, !this.hasVoted(PostVoteType.Unwanted), atRect);
- this.closeSoon();
+ toggleVote(this.state.store, post, PostVoteType.Unwanted, !this.hasVoted(PostVoteType.Unwanted),
+ atRect, this.closeSoon);
},
makeVoteButtons: function() {
@@ -633,8 +640,8 @@ const MoreVotesDropdownModal = createComponent({
});
-// CLEAN_UP use enum PostVoteType for voteType, instead of string.
-function toggleVote(store: Store, post: Post, voteType: PostVoteType, toggleOn: Bo, atRect: Rect) {
+function toggleVote(store: Store, post: Post, voteType: PostVoteType, toggleOn: Bo, atRect: Rect,
+ closeSoon?: () => V) {
const page: Page = store.currentPage;
let action: 'DeleteVote' | 'CreateVote';
let postNrsRead: PostNr[];
@@ -646,21 +653,39 @@ function toggleVote(store: Store, post: Post, voteType: PostVoteType, toggleOn:
postNrsRead = findPostNrsRead(page.postsByNr, post);
}
- morebundle.maybeChooseAnon({ store, postNr: post.nr, atRect }, (choices: ChoosenAnon) => {
- const data = {
- pageId: store.currentPageId,
- postNr: post.nr,
- vote: voteType,
- action,
- postNrsRead,
- doAsAnon: choices.doAsAnon,
- };
+ const data: SaveVotePs = {
+ pageId: store.currentPageId,
+ postNr: post.nr,
+ vote: voteType,
+ action,
+ postNrsRead,
+ };
+
+ if (toggleOn) {
+ morebundle.choosePosterPersona({ me: store.me, origins: store,
+ discStore: store, postNr: post.nr, atRect,
+ }, (doAsOpts: DoAsAndOpts | 'CANCEL') => {
+ if (closeSoon) closeSoon();
+ if (doAsOpts === 'CANCEL')
+ return;
+ data.doAsAnon = doAsOpts.doAsAnon;
+ save();
+ });
+ }
+ else {
+ // The server will delete the vote, no matter which one of pat's aliases (if any) voted.
+ // Later: Incl the id of the alias who voted, if one can create be > 1 anonym/alias
+ // per page. [one_anon_per_page].
+ if (closeSoon) closeSoon();
+ save();
+ }
+ function save() {
debiki2.Server.saveVote(data, function(storePatch: StorePatch) {
const by = storePatch.yourAnon || me_toBriefUser(store.me);
ReactActions.vote(storePatch, action, voteType, post.nr, by);
});
- });
+ }
}
@@ -760,8 +785,15 @@ const MoreDropdownModal = createComponent({
this.close();
},
- onDeleteClick: function(event) {
- morebundle.openDeletePostDialog(this.state.post, this.state.buttonRect);
+ onDeleteClick: function(event: MouseEvent) {
+ const atRect: Rect = cloneEventTargetRect(event);
+ const state = this.state;
+ const post: Post = state.post;
+ morebundle.chooseEditorPersona({ store: state.store, postNr: post.nr, atRect,
+ isInstantAction: true }, (doAsOpts: DoAsAndOpts) => {
+ morebundle.openDeletePostDialog({ post: this.state.post,
+ at: atRect, doAsAnon: doAsOpts.doAsAnon });
+ });
this.close();
},
diff --git a/client/app-slim/personas/PersonaIndicator.ts b/client/app-slim/personas/PersonaIndicator.ts
new file mode 100644
index 0000000000..8acd203707
--- /dev/null
+++ b/client/app-slim/personas/PersonaIndicator.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2024 Kaj Magnus Lindberg
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * 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
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+///
+///
+///
+///
+///
+///
+///
+
+//------------------------------------------------------------------------------
+ namespace debiki2.personas {
+//------------------------------------------------------------------------------
+
+// [persona_indicator]
+export function PersonaIndicator(ps: { store: Store, isSectionPage: Bo }): RElm | N {
+ const store = ps.store;
+ const me = store.me;
+ const personaOpts: PersonaOptions | U = store.curPersonaOptions;
+ const discProps: DiscPropsDerived | U = store.curDiscProps;
+ const mode: PersonaMode | U = store.indicatedPersona;
+
+ if (!personaOpts || !discProps || !mode)
+ return null;
+
+ const fullName = persMode_toStr(mode, Verbosity.Brief);
+ const shortName = persMode_toStr(mode, Verbosity.VeryTerse);
+
+ const title: RElm = rFr({},
+ // If using a pseudonym, show it's avatar image, if any: [pseudonyms_later]
+ !mode.pat ? null : avatar.Avatar({ user: mode.pat, origins: store, ignoreClicks: true }),
+ // If screen wide:
+ r.span({ className: 'esAvtrName_name' }, fullName),
+ // If screen narrow, always visible if narrow: [narrow]
+ r.span({ className: 'esAvtrName_Anon' }, shortName),
+ // If we'll need to ask pat which persona to use, then, show a question mark:
+ !personaOpts.isAmbiguous ? null : r.span({ className: 'c_AliAmbig' }, ' ?'));
+
+ const aliasElm =
+ Button({ className: 'esAvtrName esMyMenu s_MMB c_Tb_Ali' +
+ (!me.usePersona ? '' : ' c_Tb_Ali-Switched'),
+ onClick: (event: MouseEvent) => {
+ const atRect: Rect = cloneEventTargetRect(event);
+ morebundle.openPersonaInfoDiag({ atRect, isSectionPage: ps.isSectionPage,
+ me, personaOpts, discProps });
+ }},
+ title);
+
+ return aliasElm;
+}
+
+
+
+//------------------------------------------------------------------------------
+ }
+//------------------------------------------------------------------------------
+// vim: fdm=marker et ts=2 sw=2 tw=0 fo=tcqwn list
diff --git a/client/app-slim/personas/personas.ts b/client/app-slim/personas/personas.ts
new file mode 100644
index 0000000000..59409baf7d
--- /dev/null
+++ b/client/app-slim/personas/personas.ts
@@ -0,0 +1,533 @@
+/*
+ * Copyright (c) 2023 Kaj Magnus Lindberg
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * 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
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+///
+
+//------------------------------------------------------------------------------
+ namespace debiki2 {
+//------------------------------------------------------------------------------
+
+
+/// disc_findMyPersonas()
+///
+/// If pat is posting a reply anonymously, then, if han has posted or voted earlier
+/// anonymously on the same page, usually han wants hens new reply, to be
+/// by the same anonym, so others see they're talking with the same person
+/// (although they don't know who it is, just that it's the same).
+///
+/// This fn finds anonyms a pat has used, so the pat can reuse them. First it
+/// looks for anonyms-to-reuse in the sub thread where pats reply will appear,
+/// thereafter anywhere on the same page.
+///
+/// Returns sth like this, where 200 is pat's id, and 2001, 2002, 2003, 2004 are
+/// anons pat has used on the current page: (just nice looking numbers)
+/// {
+/// // Just an example
+/// byId: {
+/// 2001: patsAnon2001, // = { id: 2001, forPatId: 200, ... }
+/// 2002: patsAnon2002, // = { id: 2002, forPatId: 200, ... }
+/// 2003: patsAnon2003, // = { id: 2003, forPatId: 200, ... }
+/// 2004: patsAnon2004, // = { id: 2004, forPatId: 200, ... }
+/// },
+/// sameThread: [
+/// patsAnon2002, // pat's anon (or pat henself) who made pat's last comment
+// // in the path from startAtPostNr, back to the orig post.
+/// patHenself, // here pat posted using hens real account (not anonymously)
+/// patsAnon2001, // pat upvoted a comment using anon 2001, along this path
+/// ],
+/// outsideThread: [
+/// // If pat has posted earlier in the thread (closer to the orig post), using
+/// // any of the above (anon 2002, 2001, or as henself), those comments are
+/// // ignored: we don't add an anon more than once to the list.)
+///
+/// patsAnon2004, // Pat replied elsewhere on the page using hens anon 2004
+/// patsAnon2003, // ... and before that, han posted as anon 2003, also
+/// // elsewhere on the same page.
+/// ]
+/// }
+///
+/// In the above example, patsAnon2003 didn't post anything in the thread from
+/// startAtPostNr up to the orig post — but that anon did post something,
+/// *elsewhere* in the same discussion. So that anon is still in the list of anons
+/// pat might want to use again, on this page.
+///
+export function disc_findMyPersonas(discStore: DiscStore, ps: {
+ forWho: Pat | Me | U, startAtPostNr?: PostNr }): MyPersonasThisPage {
+
+ const result: MyPersonasThisPage = {
+ sameThread: [],
+ outsideThread: [],
+ byId: {},
+ };
+
+ const forWho: Pat | Me | U = ps.forWho;
+ if (!forWho || !forWho.id)
+ return result;
+
+ const curPage: Page | U = discStore.currentPage;
+ if (!curPage)
+ return result;
+
+ // ----- Same thread
+
+ // Find out if pat was henself, or was anonymous, in any earlier posts by hen,
+ // in the path from ps.startAtPostNr and back towards the orig post.
+ // (patsAnon2002, patHenself, and patsAnon2001 in the example above (i.e. in
+ // the docs comment to this fn)).
+ // (Much later: Should fetch from server, if page big – an 999 comments long thread
+ // – maybe not all comments in the middle were included by the server? [fetch_alias])
+
+ const startAtPost: Post | U = ps.startAtPostNr && curPage.postsByNr[ps.startAtPostNr];
+ const nrsSeen = {};
+ let myVotesByPostNr: { [postNr: PostNr]: Vote[] } = {};
+
+ const isMe = pat_isMe(forWho);
+ if (isMe) {
+ myVotesByPostNr = forWho.myDataByPageId[curPage.pageId]?.votesByPostNr || {};
+ }
+ else {
+ die('TyE0MYVOTS'); // [_must_be_me]
+ }
+
+ let nextPost: Post | U = startAtPost;
+ const myAliasesInThread = [];
+
+ for (let i = 0; i < StructsAndAlgs.TooLongPath && nextPost; ++i) {
+ // Cycle? (Would be a bug somewhere.)
+ if (nrsSeen[nextPost.nr])
+ break;
+ nrsSeen[nextPost.nr] = true;
+
+ // Bit dupl code: [_find_anons]
+
+ // We might have added this author, already.
+ if (result.byId[nextPost.authorId])
+ continue;
+
+ const author: Pat | U = discStore.usersByIdBrief[nextPost.authorId];
+ if (!author)
+ continue; // would be a bug somewhere, or a rare & harmless race? Oh well.
+
+ const postedAsSelf = author.id === forWho.id;
+ const postedAnonymously = author.anonForId === forWho.id;
+
+ if (postedAsSelf || postedAnonymously) {
+ // This places pat's most recently used anons first.
+ myAliasesInThread.push(author);
+ result.byId[author.id] = author;
+ }
+ else {
+ // This comment is by someone else. If we've voted anonymously, let's
+ // continue using the same anonym. Or using our main user account, if we've
+ // voted not-anonymously.
+ const votes: Vote[] = myVotesByPostNr[nextPost.nr] || [];
+ for (const myVote of votes) {
+ // If myVote.byId is absent, it's our own vote (it's not anonymous). [_must_be_me]
+ const voterId = myVote.byId || forWho.id;
+ // Have we added this alias (or our real account) already?
+ if (result.byId[voterId])
+ continue;
+ const voter: Pat = discStore.usersByIdBrief[voterId];
+ // Sometimes voters are lazy-created and added. Might be some bug. [lazy_anon_voter]
+ // @ifdef DEBUG
+ dieIf(!voter, `Voter ${voterId} missing [TyE502SRKJ5]`);
+ // @endif
+ myAliasesInThread.push(voter);
+ result.byId[voter.id] = voter;
+ }
+ }
+
+ nextPost = curPage.postsByNr[nextPost.parentNr];
+ }
+
+ // ----- Same page
+
+ // If pat posted outside [the thread from the orig post to ps.startAtPostNr],
+ // then include any anons pat used, so Pat can choose to use those anons, now
+ // when being active in sub thread startAtPostNr. (See patsAnon2003 and patsAnon2004
+ // in this fn's docs above.)
+
+ // Sleeping BUG:, ANON_UNIMPL: What if it's a really big page, and we don't have
+ // all parts here, client side? Maybe this ought to be done server side instead?
+ // Or the server could incl all one's anons on the current page, in a list [fetch_alias]
+
+ const myAliasesOutsideThread: Pat[] = [];
+
+ _.forEach(curPage.postsByNr, function(post: Post) {
+ if (nrsSeen[post.nr])
+ return;
+
+ // Bit dupl code: [_find_anons]
+
+ // Each anon pat has used, is to be included at most once.
+ if (result.byId[post.authorId])
+ return;
+
+ const author: Pat | U = discStore.usersByIdBrief[post.authorId];
+ if (!author)
+ return;
+
+ const postedAsSelf = author.id === forWho.id;
+ const postedAnonymously = author.anonForId === forWho.id;
+
+ if (postedAsSelf || postedAnonymously) {
+ myAliasesOutsideThread.push(author);
+ result.byId[author.id] = author;
+ }
+ });
+
+ _.forEach(myVotesByPostNr, function(votes: Vote[], postNrSt: St) {
+ if (nrsSeen[postNrSt])
+ return;
+
+ for (const myVote of votes) {
+ // The voter is oneself or one's anon or pseudonym. [_must_be_me]
+ const voterId = myVote.byId || forWho.id;
+
+ if (result.byId[voterId])
+ return;
+
+ const voter: Pat | U = discStore.usersByIdBrief[voterId]; // [voter_needed]
+ if (!voter)
+ return;
+
+ myAliasesOutsideThread.push(voter);
+ result.byId[voter.id] = voter;
+ }
+ });
+
+
+ // Sort, newest first. Could sort votes by voted-at, not the comment posted-at — but
+ // doesn't currently matter, not until [many_anons_per_page].
+ // Old — now both comments and votes, so won't work:
+ //myPostsOutsideThread.sort((p: Post) => -p.createdAtMs);
+ //const myPatsOutside = myPostsOutsideThread.map(p => discStore.usersByIdBrief[p.authorId]);
+
+ // ----- The results
+
+ result.sameThread = myAliasesInThread;
+ result.outsideThread = myAliasesOutsideThread;
+
+ return result;
+}
+
+
+
+/// Makes a list of personas pat can use, for replying or voting. The first
+/// item in the list is the one to use by default, and if it's unclear what
+/// pat might want, sets `PersonaOptions.isAmbiguous` in the response to true.
+///
+/// Ambiguity matrix:
+///
+/// D = the persona in the left column is automatically used (has priority).
+/// d = We'll ask which persona pat wants to use, and this persona (the one in
+/// the left column) is the default, listed first.
+/// Not totally implemented for the editor though?
+/// - = ignored, doesn't have priority
+/// A = ambiguous, should ask the user
+/// (AP = ambiguous, should ask and update the Preferred-persona-on-page.
+/// But not implemented – means A instead, for now.)
+///
+/// None Thread (PrfOnP) Page Recom Pers Mode
+/// None n/a - - - - -
+/// Persona in Thread* D n/a A d D d
+/// (Preferred Persona on Page)** D d n/a D D d
+/// Persona on Page D - - n/a D A
+/// Recommended Persona d - - - n/a -
+/// Persona mode persona D A A A D n/a
+///
+/// * "Persona in Thread" means that pat has replied or voted earlier in the same
+/// page, same sub thread, as that persona. Example:
+///
+/// A comment by Alice
+/// `—> A comment by this user as anonym A <—— this persona (anonym A) is in
+/// | `MyPersonasThisPage.sameThread`
+/// `—> A comment by Bob
+/// `—> Here our user starts replying to Bob,
+/// and findPersonaOptions() gets called.
+/// Our user likely wants to reply as anonym A again.
+/// Some other comment, same page but not same sub thread
+/// `—> A reply
+/// `——> A comment by our <— this persona (the user hanself) is
+/// user, as hanself in `MyPersonasThisPage.outsideThread`, and
+/// that's "Persona on Page" in the table above
+///
+/// ** "Preferred Persona on Page" is if in the future it'll be possible to
+/// remember "Always use this persona, on this page" — so won't have to choose,
+/// repeatedly. But maybe that's just an over complicated idea.
+///
+export function findPersonaOptions(ps: {
+ // Missing, if on a forum homepage (no discussions directly on such pages).
+ myPersonasThisPage?: MyPersonasThisPage,
+ me: Me,
+ // Missing, if on an auto generated page (rather than a discussion page).
+ // Would be good with site-default props [site_disc_props] instead of `undefined`.
+ discProps?: DiscPropsDerived,
+ }): PersonaOptions {
+
+ const dp: DiscPropsDerived | U = ps.discProps;
+ const anonsAllowed = dp && dp.comtsStartAnon >= NeverAlways.Allowed;
+ const anonsRecommended = dp && dp.comtsStartAnon >= NeverAlways.Recommended;
+ //nst pseudonymsRecommended = false; // [pseudonyms_later]
+ const mustBeAnon = dp && dp.comtsStartAnon >= NeverAlways.AlwaysButCanContinue;
+ const newAnonStatus = dp && dp.newAnonStatus;
+ const selfRecommended = !anonsRecommended; // later: dp.comtsStartAnon <= AllowedMustChoose
+
+ // @ifdef DEBUG
+ dieIf(anonsAllowed && !newAnonStatus, `[TyE4WJE281]`);
+ dieIf(anonsRecommended && !anonsAllowed, `[TyE4WJE282]`);
+ dieIf(mustBeAnon && !anonsRecommended, `[TyE4WJE282]`);
+ // @endif
+
+ const myPersThisPage: MyPersonasThisPage | U = ps.myPersonasThisPage;
+ const me = ps.me;
+
+ const result: PersonaOptions = { isAmbiguous: false, optsList: [] };
+ let selfAdded = false;
+ let anonFromPropsAdded = false;
+ let modePersonaAdded = false;
+ let recommendedAdded = false;
+
+ // ----- Personas from the current discussion
+
+ if (!myPersThisPage) {
+ // We're on a forum homeage, or user profile page, or sth like that, which
+ // itself has no discussions or comments.
+ }
+ else {
+ // We're on some discussion page, with comments, authors, votes.
+
+ // Same sub thread has priority – so we continue replying as the same person,
+ // won't become sbd else in the middle of a thread.
+ for (let ix = 0; ix < myPersThisPage.sameThread.length; ++ix) {
+ const pat = myPersThisPage.sameThread[ix];
+ const opt: PersonaOption = {
+ alias: pat,
+ doAs: patToMaybeAnon(pat, me),
+ // The first item is who pat most recently replied or voted as, in the relevant sub thread.
+ isBestGuess: ix === 0,
+ inSameThread: true,
+ }
+ initOption(opt);
+ result.optsList.push(opt);
+ }
+
+ // Thereafter, elsewhere on the same page, but not in the same sub thread.
+ for (let ix = 0; ix < myPersThisPage.outsideThread.length; ++ix) {
+ const pat = myPersThisPage.outsideThread[ix];
+ const opt: PersonaOption = {
+ alias: pat,
+ doAs: patToMaybeAnon(pat, me),
+ isBestGuess:
+ !myPersThisPage.sameThread.length &&
+ // If pat has commented using different aliases on this page, we can't
+ // know which one is the best guess? Maybe the most recently used one?
+ // Or the most frequently used one? Who knows.
+ myPersThisPage.outsideThread.length === 1,
+ onSamePage: true,
+ }
+ initOption(opt);
+ result.optsList.push(opt);
+ }
+ }
+
+ // ----- Persona mode
+
+ // The user might be in e.g. anon mode also on pages without any discussions.
+
+ if (me.usePersona && !modePersonaAdded) {
+ let opt: PersonaOption;
+ if (me.usePersona.self) {
+ opt = {
+ alias: me,
+ doAs: false, // not anon, be oneself [oneself_0_false]
+ isFromMode: true,
+ isSelf: true,
+ };
+ if (mustBeAnon) {
+ opt.isNotAllowed = true;
+ }
+ if (selfRecommended) {
+ opt.isRecommended = true;
+ recommendedAdded = true;
+ }
+ selfAdded = true;
+ }
+ else if (me.usePersona.anonStatus) {
+ // Since not added above (!modePersonaAdded), this'd be a new anon.
+ const anonStatus = me.usePersona.anonStatus;
+ const anonPat = anon_create({ anonStatus, anonForId: me.id });
+ opt = {
+ alias: anonPat,
+ doAs: patToMaybeAnon(anonPat, me),
+ isFromMode: true,
+ };
+ if (!anonsAllowed) {
+ opt.isNotAllowed = true;
+ }
+ if (anonStatus === newAnonStatus) {
+ if (anonsRecommended) {
+ opt.isRecommended = true;
+ recommendedAdded = true;
+ }
+ opt.isFromProps = true;
+ anonFromPropsAdded = true; // [dif_anon_status]
+ }
+ }
+ else {
+ // [pseudonyms_later] ?
+ }
+
+ result.optsList.push(opt);
+ }
+
+ // Are there more than two persona options? Then we don't know which one to use.
+ //
+ // (We ignore additional options added below. Only any Persona Mode option (added above),
+ // and personas pat has been on the current page (also added above), can make us unsure
+ // about who pat wants to be now. But not just because anonymity or posting as oneself
+ // is the default / recommended on the page.)
+ //
+ result.isAmbiguous = result.optsList.length >= 2;
+
+ // Add Anonymous as an option, if not done already.
+ if (!anonFromPropsAdded && anonsAllowed) {
+ const anonPat = anon_create({ anonStatus: newAnonStatus, anonForId: me.id });
+ const opt: PersonaOption = {
+ alias: anonPat,
+ doAs: patToMaybeAnon(anonPat, me),
+ };
+ if (!recommendedAdded && anonsRecommended) {
+ // (It's the recommended type of anon — we created it with newAnonStatus just above.)
+ opt.isRecommended = true;
+ recommendedAdded = true;
+ }
+ result.optsList.push(opt);
+
+ // If not in alias mode, maybe it's good to tell the user that han can be anonymous
+ // if han wants? – No, this feels just annoying. If someone started commenting
+ // on a page as hanself, then it's pretty pointless to suddenly have han replaced by
+ // "Anon 123" in subsequent comments, when "everyone" can guess who Anon 123 is anyway.
+ // if (ambiguities2.aliases.length >= 2 && !me.usePersona && anonsRecommended) {
+ // ambiguities2.isAmbiguous = true;
+ // }
+ }
+
+ // Add oneself as an option, if not done already. (But don't consider this an ambiguity.)
+ if (!selfAdded && !mustBeAnon) {
+ const opt: PersonaOption = {
+ alias: me,
+ doAs: false, // not anon [oneself_0_false]
+ isSelf: true,
+ };
+ if (!recommendedAdded && selfRecommended) {
+ opt.isRecommended = true;
+ recommendedAdded = true;
+ }
+ result.optsList.push(opt);
+ }
+
+ result.optsList.sort((a: PersonaOption, b: PersonaOption) => {
+ if (a.isNotAllowed !== b.isNotAllowed)
+ return a.isNotAllowed ? +1 : -1; // place `a` last
+
+ if (a.isBestGuess !== b.isBestGuess)
+ return a.isBestGuess ? -1 : +1; // place `a` first
+
+ if (a.isFromMode !== b.isFromMode)
+ return a.isFromMode ? -1 : +1;
+
+ if (a.isRecommended !== b.isRecommended)
+ return a.isRecommended ? -1 : +1;
+
+ if (a.isSelf !== b.isSelf)
+ return a.isSelf ? -1 : +1;
+ })
+
+ function initOption(opt: PersonaOption) {
+ const pat: Pat = opt.alias;
+ const isMe = pat.id === me.id;
+ const isAnonFromProps = pat.anonStatus && pat.anonStatus === newAnonStatus;
+
+ if (isMe) {
+ opt.isSelf = true;
+ selfAdded = true;
+ result.hasBeenSelf = true;
+ }
+
+ if (pat.isAnon) {
+ // (opt.isAnon not needed — opt.alias.isAnon is enough.)
+ if (isAnonFromProps) {
+ opt.isFromProps = true;
+ anonFromPropsAdded = true; // [dif_anon_status]
+ }
+ result.hasBeenAnon = true;
+ }
+
+ // if (...) [pseudonyms_later]
+
+ // Is the same persona as any current Persona Mode?
+ if (me.usePersona) {
+ const isSelfFromMode = me.usePersona.self && isMe;
+ const isAnonFromMode = me.usePersona.anonStatus === pat.anonStatus && pat.anonStatus;
+ if (isSelfFromMode || isAnonFromMode) {
+ opt.isFromMode = true;
+ modePersonaAdded = true;
+ }
+ }
+
+ // Any persona mode anonym, might not be of the recommended anonymity status. [dif_anon_status]
+ // But one from the category properties, would be.
+ if ((isAnonFromProps && anonsRecommended) || (isMe && selfRecommended)) {
+ opt.isRecommended = true;
+ recommendedAdded = true;
+ }
+
+ if ((pat.isAnon && !anonsAllowed) || (isMe && mustBeAnon)) {
+ opt.isNotAllowed = true;
+ }
+ }
+
+ return result;
+}
+
+
+export function patToMaybeAnon(p: Pat | KnownAnonym | NewAnon, me: Me): MaybeAnon {
+ if ((p as WhichAnon).createNew_tst) {
+ // Then `p` is a WhichAnon already, not a Pat.
+ return p as NewAnon;
+ }
+
+ const pat = p as Pat;
+
+ if (pat.id === me.id) {
+ return false; // means not anon, instead, oneself [oneself_0_false]
+ }
+ else if (pat.id === Pats.FutureAnonId) {
+ // Skip `NewAnon.createNew_tst` for now. [one_anon_per_page]
+ return { anonStatus: pat.anonStatus, lazyCreate: true } satisfies LazyCreatedAnon;
+ }
+ else {
+ return { anonStatus: pat.anonStatus, sameAnonId: pat.id } as SameAnon;
+ }
+}
+
+
+//------------------------------------------------------------------------------
+ }
+//------------------------------------------------------------------------------
+// vim: fdm=marker et ts=2 sw=2 tw=0 fo=r list
diff --git a/client/app-slim/sidebar/sidebar.ts b/client/app-slim/sidebar/sidebar.ts
index e5de7c3f25..9fb9486c70 100644
--- a/client/app-slim/sidebar/sidebar.ts
+++ b/client/app-slim/sidebar/sidebar.ts
@@ -241,6 +241,7 @@ export var Sidebar = createComponent({ // RENAME to ContextBar
// Find 1) all unread comments, sorted in the way they appear on the page
// And 2) all visible comments.
+ // Should fetch from server, if page big. [fetch_alias]
const addRecursively = (postNrs: number[]) => {
_.each(postNrs, (postNr) => {
const post: Post = page.postsByNr[postNr];
@@ -281,7 +282,7 @@ export var Sidebar = createComponent({ // RENAME to ContextBar
const rootPost = page.postsByNr[store.rootPostId];
addRecursively(rootPost.childNrsSorted);
- _.each(page.postsByNr, (child: Post, childId) => {
+ _.each(page.postsByNr, (child: Post) => {
if (child.postType === PostType.Flat) {
addPost(child);
}
@@ -407,7 +408,7 @@ export var Sidebar = createComponent({ // RENAME to ContextBar
title = ''; // "People here recently:" // I18N, unimplemented
}
else {
- const titleText = isChat ? t.cb.UsersInThisChat : t.cb.UsersInThisTopic;
+ const titleText = isChat ? t.cb.UsersInThisChat : t.cb.UsersInThisTopic; // [users_here]
title = r.div({},
titleText,
r.span({ className: 'esCtxbar_onlineCol' }, t.Online));
diff --git a/client/app-slim/slim-bundle.d.ts b/client/app-slim/slim-bundle.d.ts
index 1c103246e3..506e6f314e 100644
--- a/client/app-slim/slim-bundle.d.ts
+++ b/client/app-slim/slim-bundle.d.ts
@@ -389,7 +389,8 @@ declare namespace debiki2 {
function user_isTrustMinNotThreat(me: UserInclDetails | Myself, trustLevel: TrustLevel): boolean;
//function threatLevel_toString(threatLevel: ThreatLevel): [St, St];
function threatLevel_toElem(threatLevel: ThreatLevel);
- function pat_name(pat: Me | Pat): St;
+ function persMode_toStr(mode: PersonaMode, verb: Verbosity): St;
+ function pat_name(pat: Me | Pat | LazyCreatedAnon | NewAnon): St;
function pat_isMe(pat: UserInclDetails | Me | Pat | PatId): pat is Me;
function pat_isMember(pat: UserInclDetails | Me | Pat | PatId): Bo;
var isGuest;
@@ -401,11 +402,18 @@ declare namespace debiki2 {
function store_maySendDirectMessageTo(store: Store, user: UserInclDetails): boolean;
function pat_isBitAdv(pat: PatVb | Me): Bo;
function pat_isMoreAdv(pat: PatVb | Me): Bo;
+ function anonStatus_toStr(anonStatus: AnonStatus, verb?: Verbosity): St;
var page_isGroupTalk;
function store_getAuthorOrMissing(store: DiscStore, post: Post): Pat;
function store_getUserOrMissing(store: DiscStore, userId: PatId, errorCode2?: St): Pat;
- var store_thisIsMyPage;
+ function store_thisIsMyPage(store: DiscStore): Bo;
+ function disc_findMyPersonas(discStore: DiscStore, ps: {
+ forWho: Pat | Me | U, startAtPostNr?: PostNr }): MyPersonasThisPage;
+
+ function findPersonaOptions(ps: { myPersonasThisPage?: MyPersonasThisPage, me: Me,
+ discProps: DiscPropsDerived }): PersonaOptions;
+ function patToMaybeAnon(p: Pat | KnownAnonym | NewAnon, me: Me): MaybeAnon;
function draftType_toPostType(draftType: DraftType): PostType | U;
function postType_toDraftType(postType: PostType): DraftType | U;
@@ -511,7 +519,7 @@ declare namespace debiki2 {
function timeExact(whenMs: number, clazz?: string);
namespace avatar {
- var Avatar;
+ function Avatar(props: AvatarProps): RElm;
}
function pageNotfPrefTarget_findEffPref(target: PageNotfPrefTarget, store: Store, ownPrefs: OwnPageNotfPrefs): EffPageNotfPref;
diff --git a/client/app-slim/start-page.ts b/client/app-slim/start-page.ts
index ad27521d1a..ee04005bfb 100644
--- a/client/app-slim/start-page.ts
+++ b/client/app-slim/start-page.ts
@@ -282,6 +282,7 @@ function renderPageInBrowser() {
debiki2.processTimeAgo(numPosts > 20 ? '.dw-ar-p-hd' : '');
const timeAfterTimeAgo = performance.now();
+ // (Also calls ReactStore.activateMyself().)
debiki2.ReactStore.activateVolatileData();
const timeAfterUserData = performance.now();
diff --git a/client/app-slim/start-stuff.ts b/client/app-slim/start-stuff.ts
index cecabdf370..6a1b921cd5 100644
--- a/client/app-slim/start-stuff.ts
+++ b/client/app-slim/start-stuff.ts
@@ -100,4 +100,4 @@ debiki2.dieIf(location.port && eds.debugOrigin.indexOf(':' + location.port) ===
}
-// vim: fdm=marker et ts=2 sw=2 fo=tcqwn list
\ No newline at end of file
+// vim: fdm=marker et ts=2 sw=2 fo=tcqwn list
diff --git a/client/app-slim/store-getters.ts b/client/app-slim/store-getters.ts
index c488489ebe..894f4772f3 100644
--- a/client/app-slim/store-getters.ts
+++ b/client/app-slim/store-getters.ts
@@ -35,7 +35,7 @@ export function pat_isAuthorOf(pat: Me | Pat, post: Post, patsById: PpsById): Bo
// @ifdef DEBUG
dieIf(!post, 'TyE2065MRTJ3');
// @endif
- // If pat typeof Me, and not logged in, .id is undefined.
+ // If pat typeof Me, and not logged in, pat.id is Pats.NoPatId == 0, zero.
if (!pat.id || !post) return false;
// If pat used hens real account (or if pat is an anonym and the post author too).
if (pat.id === post.authorId) return true;
@@ -45,7 +45,7 @@ export function pat_isAuthorOf(pat: Me | Pat, post: Post, patsById: PpsById): Bo
}
-export function store_thisIsMyPage(store: Store): boolean {
+export function store_thisIsMyPage(store: DiscStore): Bo {
const page: Page = store.currentPage;
if (!page || !store.me.id) return false;
const me: Me = store.me;
@@ -75,14 +75,14 @@ export function store_getAuthorOrMissing(store: DiscStore, post: Post): Pat {
}
// If replying using a new anonym, its future id is not yet konw:
+ // (This happens when *previewing* one's first anonymous comments on a page. Once
+ // the comment gets saved, an anonym is created and gets an id.)
if (post.authorId === Pats.FutureAnonId) {
- return {
- id: Pats.FutureAnonId,
- // We don't know for sure what name sequence number this anonym will get,
- // so let's use '?' instead of A1 or A2 etc.
- fullName: "Anonym (you)",
- isAnon: true,
- };
+ // @ifdef DEBUG
+ dieIf(!post.doAsAnon, 'TyE6032SKGN4');
+ // @endif
+ const anonStatus = post.doAsAnon && post.doAsAnon.anonStatus;
+ return anon_create({ anonStatus, anonForId: store.me.id, isMe: true });
}
const user = store_getUserOrMissing(store, post.authorId);
@@ -108,7 +108,7 @@ export function store_getUserOrMissing(store: DiscStore, userId: PatId,
// so it'll be easier to debug-find-out that something is amiss.
fullName: `□ missing, id: ${userId} [EsE4FK07_]`,
isMissing: true,
- };
+ } satisfies Pat;
}
return user;
}
@@ -129,6 +129,7 @@ export function store_getUsersOnThisPage(store: Store): BriefUser[] {
const page: Page = store.currentPage;
const users: BriefUser[] = [];
_.each(page.postsByNr, (post: Post) => {
+ // Isn't this [On2]? Or rather num-authors*num-posts?
if (_.every(users, u => u.id !== post.authorId)) {
const user = store_getAuthorOrMissing(store, post);
users.push(user);
@@ -195,15 +196,14 @@ export function store_canDeletePage(store: Store): Bo {
!isSection(page.pageRole) || store_numSubCommunities(store) > 1);
return someoneCanDelete && (
isStaff(store.me) || (
- page_authorId(page) === store.me.id &&
- page.numRepliesVisible == 0));
+ store_thisIsMyPage(store) && page.numRepliesVisible == 0));
}
export function store_canUndeletePage(store: Store): Bo {
const page: Page = store.currentPage;
const pat = store.me;
- return !!page.pageDeletedAtMs && (isStaff(pat) || page_authorId(page) === pat.id);
+ return !!page.pageDeletedAtMs && (isStaff(pat) || store_thisIsMyPage(store));
// later, change to: page.deletedById === pat.id
}
// -------
@@ -253,6 +253,7 @@ export function store_numSubCommunities(store: Store): number {
export function store_thereAreFormReplies(store: Store): boolean {
+ // Should fetch from server, if page big. [fetch_alias]
const page: Page = store.currentPage;
return _.some(page.postsByNr, (post: Post) => {
return post.postType === PostType.CompletedForm;
diff --git a/client/app-slim/topbar/topbar.styl b/client/app-slim/topbar/topbar.styl
index b6012d014e..e25f50eaeb 100644
--- a/client/app-slim/topbar/topbar.styl
+++ b/client/app-slim/topbar/topbar.styl
@@ -295,9 +295,12 @@ html.c_Html-EdLeft #esPageColumn
margin-top: 0;
-.esTB_SearchBtn
- padding: 2px 5px 0px 4px;
+.DW.DW.DW .esTB_SearchBtn
+ padding: 5px 5px 5px 4px
margin: 0;
+ vertical-align: middle;
+
+.esTB_SearchBtn
.icon-search
color: hsl(0, 0%, 50%);
font-size: 21px;
@@ -357,19 +360,22 @@ html.c_Html-EdLeft #esPageColumn
opacity: 1;
.esAvtrName_you,
- .esAvtrName_name
+ .esAvtrName_name,
+ .esAvtrName_Anon
color: #666;
letter-spacing: 0.2px;
vertical-align: middle;
font-size: 13.5px;
// Replace the user's perhaps long username with "You" if screen too narrow.
- .esAvtrName_you
+ .esAvtrName_you,
+ .esAvtrName_Anon
display: none;
@media (max-width: 640px)
.esAvtrName_name
display: none;
- .esAvtrName_you
+ .esAvtrName_you,
+ .esAvtrName_Anon
display: inline-block;
@media (max-width: 480px)
.esAvtrName_you
@@ -477,11 +483,17 @@ $snoozeIconSize = 28px;
position: relative;
top: 1px;
+// What's this? Adds padding without breaking right-alignment with things below in
+// of the same width?
.esMyMenu
padding-right: 6px;
position: relative;
right: -6px;
+// Dim one's true account, if using an alias (so the alias becomes more prominent).
+.s_MMB.n_AliOn
+ opacity: 0.83;
+
.esMyMenu .esAvtr
margin-right: 5px;
@@ -514,6 +526,32 @@ $snoozeIconSize = 28px;
left: 0;
top: 6px;
+.c_Tb_AliAw
+ font-size: 150%;
+ margin: 0 -6px 0 4px;
+ @media (max-width: 640px)
+ margin: 0 -9px 0 0px;
+
+.DW .c_Tb_Ali.s_MMB.btn
+ padding: 16px 6px;
+ margin-left: -6px; // cancel padding (+padding -margin —> button larger)
+
+.c_Tb_Ali-Switched.btn
+ font-weight: bold;
+ text-decoration: underline;
+ text-underline-offset: 6px;
+
+// Make [the question mark indicating an alias ambiguity] simpler to spot.
+.c_AliAmbig
+ font-size: 110%;
+ vertical-align: middle;
+
+.c_PersInfD_Bs
+ margin: 23px 0px 9px;
+ .btn
+ margin: 0 15px 10px 0;
+
+
.esTopbar
position: relative;
z-index: 5;
@@ -587,12 +625,15 @@ html.esSidebarsOverlayPage
.s_Tb_CuNv,
.s_Tb_Pg_Cs,
.s_Tb_MyBs
- margin-top: 15px;
+ margin-top: 17px;
.s_Tb
.s_Tb_MyBs
white-space: nowrap;
.btn
+ vertical-align: baseline;
+ padding-top: 2px;
+ line-height: 1;
border-radius: 0;
border: none;
diff --git a/client/app-slim/topbar/topbar.ts b/client/app-slim/topbar/topbar.ts
index a7dad37bc2..481c0f4efb 100644
--- a/client/app-slim/topbar/topbar.ts
+++ b/client/app-slim/topbar/topbar.ts
@@ -15,6 +15,7 @@
* along with this program. If not, see .
*/
+///
///
///
///
@@ -30,6 +31,7 @@
///
///
///
+///
//------------------------------------------------------------------------------
namespace debiki2.topbar {
@@ -316,17 +318,32 @@ export const TopBar = createComponent({
const otherNotfs = makeNotfIcon('other', me.numOtherNotfs);
let isImpersonatingClass = '';
- let impersonatingStrangerInfo;
+ let impersonatingStrangerInfo: St | U;
if (store.isImpersonating) {
isImpersonatingClass = ' s_MMB-IsImp';
if (!me.isLoggedIn) {
isImpersonatingClass += ' s_MMB-IsImp-Stranger';
- impersonatingStrangerInfo = "Viewing as stranger"; // (skip i18n, is for staff)
+ impersonatingStrangerInfo = "Viewing as stranger"; // (0I18N, is for staff)
// SECURITY COULD add a logout button, so won't need to first click stop-viewing-as,
// and then also click Logout. 2 steps = a bit risky, 1 step = simpler, safer.
}
}
+
+ // ------- Alias info
+
+ // Shows if we're anonymous or have switched to any pseudonym.
+
+ const aliasInfo: RElm | N = !store.userSpecificDataAdded ? null :
+ personas.PersonaIndicator({ store, isSectionPage });
+
+ // @ifdef DEBUG
+ // COULD_OPTIMIZE: This actually happens — we're rerendering at least once too much?
+ //dieIf(me.usePersona && !aliasInfo, 'TyE60WMJLS256');
+ // @endif
+
+ // ------- Username (click opens: ../../app-more/topbar/my-menu.more.ts)
+
const myAvatar = !me.isLoggedIn ? null :
avatar.Avatar({ user: me, origins: store, ignoreClicks: true });
@@ -351,10 +368,11 @@ export const TopBar = createComponent({
const avatarNameDropdown = !me.isLoggedIn && !impersonatingStrangerInfo ? null :
utils.ModalDropdownButton({
- // RENAME 'esMyMenu' to 's_MMB' (my-menu button).
- className: 'esAvtrName esMyMenu s_MMB' + isImpersonatingClass, // CLEAN_UP RENAME to s_MMB
+ // RENAME 'esMyMenu' to 'c_MMB' (my-menu button).
+ className: 'esAvtrName esMyMenu s_MMB' + // CLEAN_UP RENAME to c_MMB
+ isImpersonatingClass +
+ (me.usePersona ? ' n_AliOn' : ''),
dialogClassName: 's_MM',
- ref: 'myMenuButton',
showCloseButton: true,
bottomCloseButton: true,
// MyMenu might list many notifications, and people Command-Click to open
@@ -375,7 +393,8 @@ export const TopBar = createComponent({
talkToMeNotfs,
talkToOthersNotfs,
otherNotfs,
- snoozeIcon) },
+ snoozeIcon,
+ ) },
MyMenuContentComponent({ store }));
@@ -533,7 +552,9 @@ export const TopBar = createComponent({
// We don't know when CSS hides or shows the 2nd row, so better to
// include always.
toolsButton,
- avatarNameDropdown);
+ avatarNameDropdown,
+ aliasInfo && r.span({ className: 'c_Tb_AliAw' }, "→ "),
+ aliasInfo);
// No:
//(!use2Rows || justOneRow) && toolsButton,
//(!use2Rows || justOneRow) && avatarNameDropdown);
diff --git a/client/app-slim/translations.d.ts b/client/app-slim/translations.d.ts
index 93cc18833d..d7e7c052a8 100644
--- a/client/app-slim/translations.d.ts
+++ b/client/app-slim/translations.d.ts
@@ -26,7 +26,16 @@ interface TalkyardTranslations {
AddComment?: string;
Admin: string;
AdvSearch: string;
+ // Wrap all these "Temp", "Temp Anon", "Perma.. Anon.." in an `anon: {...}` obj, [anon_2_str]
+ // so they'll end up next to each other?
+ //Temp?: string;
+ //TempAnon?: string;
+ //TempAnonym?: string;
+ //TemporarilyAnonymous?: string;
+ //Anon?: string;
Anonym?: string;
+ //Anonymous?: string;
+ //PermanentlyAnonymous?: string;
Away: string;
Back: string;
BlogN: string;
diff --git a/client/app-slim/util/ExplainingDropdown.styl b/client/app-slim/util/ExplainingDropdown.styl
index addfbc7a96..4d5a62e0f2 100644
--- a/client/app-slim/util/ExplainingDropdown.styl
+++ b/client/app-slim/util/ExplainingDropdown.styl
@@ -78,3 +78,21 @@ $sideSpace = 20px;
.esExplDrp_entry_sub
margin: 8px 20px 16px;
+
+// If there's an image/icon to the left of the tilte & text.
+.esExplDrp_entry:has(.esExplDrp_entry_img) button
+ display: flex;
+ // The images a bit function as padding-left, so reduce from $sideSpace = 20px
+ // to just .esDropModal_header's padding-left = 11px. [proxy_diag_padding]
+ padding-left: 11px;
+
+.esExplDrp_entry_img
+ flex: 0
+
+ .esAvtr
+ position: static;
+ margin-right: 15px;
+
+.esExplDrp_entry_TtlTxt
+ flex: 1
+
diff --git a/client/app-slim/util/ExplainingDropdown.ts b/client/app-slim/util/ExplainingDropdown.ts
index 2df4f4030a..2fd931739b 100644
--- a/client/app-slim/util/ExplainingDropdown.ts
+++ b/client/app-slim/util/ExplainingDropdown.ts
@@ -81,6 +81,15 @@ export var ExplainingListItem = createComponent({
const elemFn = isButton ? r.button : (props.linkTo ? LinkUnstyled : r.a);
+ let imgTitleText = rFr({},
+ r.div({ className: 'esExplDrp_entry_title' }, entry.title),
+ r.div({ className: 'esExplDrp_entry_expl' }, entry.text));
+ if (props.img) {
+ imgTitleText = rFr({},
+ r.div({ className: 'esExplDrp_entry_img' }, props.img),
+ r.div({ className: 'esExplDrp_entry_TtlTxt' }, imgTitleText));
+ }
+
return (
r.li({ className: 'esExplDrp_entry' + activeClass + disabledClass },
elemFn.apply(this, [
@@ -96,8 +105,7 @@ export var ExplainingListItem = createComponent({
},
tabIndex: props.tabIndex || 1000,
},
- r.div({ className: 'esExplDrp_entry_title' }, entry.title),
- r.div({ className: 'esExplDrp_entry_expl' }, entry.text)]),
+ imgTitleText]),
subStuff));
},
});
diff --git a/client/app-slim/utils/DropdownModal.styl b/client/app-slim/utils/DropdownModal.styl
index aaad222e6f..8ae87214ff 100644
--- a/client/app-slim/utils/DropdownModal.styl
+++ b/client/app-slim/utils/DropdownModal.styl
@@ -17,6 +17,8 @@
max-width: 560px; // or annoyingly wide. [dropdown_width]
.esDropModal_CloseB
+ // This doesn't work well: sometimes overlaps long contents.
+ // Wouldn't float: right be better? But need one extra ? [close_cross_css]
position: absolute;
right: 0;
top: 0;
@@ -36,7 +38,7 @@
.esDropModal_header
font-style: italic;
font-size: 14px;
- margin: 14px 0 4px 11px;
+ margin: 14px 0 4px 11px; // [proxy_diag_padding]
&:first-child
margin-top: 3px;
diff --git a/client/app-slim/utils/DropdownModal.ts b/client/app-slim/utils/DropdownModal.ts
index fd5c50cbb9..243fbce3de 100644
--- a/client/app-slim/utils/DropdownModal.ts
+++ b/client/app-slim/utils/DropdownModal.ts
@@ -224,6 +224,7 @@ export const DropdownModal = createComponent({
const props: DropdownProps = this.props;
let content;
if (props.show) {
+ // Need one more
to be able to use float: right for the close [x]? [close_cross_css]
const closeButton = !props.showCloseButton ? null :
r.div({ className: 'esDropModal_CloseB esCloseCross', onClick: props.onHide });
diff --git a/client/app-slim/widgets.ts b/client/app-slim/widgets.ts
index cdb4df8213..f2cbbfa80e 100644
--- a/client/app-slim/widgets.ts
+++ b/client/app-slim/widgets.ts
@@ -300,8 +300,7 @@ export function UserName(props: {
let namePartTwo: St | RElm | U;
if (user.isAnon) {
- // There's already "By" before, and "anonym" isn't a name, so use lowercase.
- namePartOne = r.span({className: 'esP_By_F esP_By_F-G' }, t.Anonym);
+ namePartOne = r.span({className: 'esP_By_F esP_By_F-G' }, anonStatus_toStr(user.anonStatus));
if (props.store && user.anonForId) { // maybe always take a DiscStore as fn props?
const store = props.store;
if (store.me.id === user.anonForId) {
diff --git a/client/server/personas/PersonaIndicator.ts b/client/server/personas/PersonaIndicator.ts
new file mode 100644
index 0000000000..90daf470f8
--- /dev/null
+++ b/client/server/personas/PersonaIndicator.ts
@@ -0,0 +1,16 @@
+///
+
+//------------------------------------------------------------------------------
+ namespace debiki2.personas {
+//------------------------------------------------------------------------------
+
+
+export function PersonaIndicator(ps: any): RElm | N {
+ return null;
+}
+
+
+//------------------------------------------------------------------------------
+ }
+//------------------------------------------------------------------------------
+// vim: fdm=marker et ts=2 sw=2 tw=0 fo=tcqwn list
diff --git a/client/types-and-const-enums.ts b/client/types-and-const-enums.ts
index 7095b406d2..a0ef058c05 100644
--- a/client/types-and-const-enums.ts
+++ b/client/types-and-const-enums.ts
@@ -755,6 +755,17 @@ const enum DiagFlavor {
}
+const enum Verbosity {
+ // Can be nice for [power_users], e.g. "T" or "TA" for "Temporarily Anonymous",
+ // and they'd know what it means.
+ //Letters = 1,
+ VeryTerse = 2,
+ Terse = 3,
+ Brief = 4,
+ Full = 6,
+}
+
+
const enum Time {
OneDayInMillis = 24 * 3600 * 1000,
}
diff --git a/docs/abbreviations.txt b/docs/abbreviations.txt
index 0153ec9ec4..4a7bbbedd4 100644
--- a/docs/abbreviations.txt
+++ b/docs/abbreviations.txt
@@ -86,10 +86,15 @@ And t_ —> i_ for 'id' attr.
s_w-X = wraps an X, i.e., somewhere inside, there's an X. Or "'W'ith a X inside".
w_X = wraps X. Always a class, not an id.
+0I18N: Internationalization not needed (0 = zero = not), that is, no need to
+ translate to different languages – only English is fine for now. (Typically because
+ the message is for admins only, who are supposed to know some English.)
+
In-the-middle or at-a-word-end abbreviations: (but not at the start of the selector)
Ab = about
Adm = admin
Aft = after
+Ali = alias (anonym or pseudonym)
Alw = allow(ed)
Ann, An = announcement, also see SAn: Server Announcement
Asg = assign
@@ -180,6 +185,7 @@ Nv = navigation, e.g. NvLs_Ln = Navigation-List Link
Nw = new
Op = Original post
Ord = order
+Par = parenhtesis? Maybe sth else too
Pat = participant
Pb = pagebar
Pf = preference, e.g. NfPfs = notification preferences
diff --git a/docs/db-schema/README.txt b/docs/db-schema/README.txt
index 0b37e01aea..c8a078f666 100644
--- a/docs/db-schema/README.txt
+++ b/docs/db-schema/README.txt
@@ -9,14 +9,14 @@ for table_name in $(docker-compose exec -T rdb psql -c '\d' talkyard talkyard \
| awk '{ print $3 }')
do
docker-compose exec -T rdb psql talkyard talkyard -c '\d '"$table_name" \
- | tee ./docs/db-schema-dump/$table_name.txt
+ | tee ./docs/db-schema/$table_name.txt
done
# Custom domains, with and without comments:
docker-compose exec -T rdb psql talkyard talkyard -c '\dD+' \
- | tee ./docs/db-schema-dump/domains.txt
+ | tee ./docs/db-schema/domains.txt
docker-compose exec -T rdb psql talkyard talkyard -c '\dD' \
- | tee ./docs/db-schema-dump/domains-no-comments.txt
+ | tee ./docs/db-schema/domains-no-comments.txt
```
Details: \d lists all tables and sequences, this format:
diff --git a/docs/db-schema/drafts3.txt b/docs/db-schema/drafts3.txt
index f23b32fe9a..a992ef88b8 100644
--- a/docs/db-schema/drafts3.txt
+++ b/docs/db-schema/drafts3.txt
@@ -19,6 +19,7 @@
text | character varying | | not null |
new_anon_status_c | anonym_status_d | | |
post_as_id_c | pat_id_d | | |
+ order_c | f32_d | | |
Indexes:
"drafts_byuser_nr_p" PRIMARY KEY, btree (site_id, by_user_id, draft_nr)
"drafts_byuser_deldat_i" btree (site_id, by_user_id, deleted_at DESC) WHERE deleted_at IS NOT NULL
diff --git a/docs/db-schema/notifications3.txt b/docs/db-schema/notifications3.txt
index 0cbd87a73d..ec8f081e16 100644
--- a/docs/db-schema/notifications3.txt
+++ b/docs/db-schema/notifications3.txt
@@ -21,32 +21,38 @@
about_tag_id_c | tag_id_d | | |
about_thing_type_c | thing_type_d | | |
about_sub_type_c | sub_type_d | | |
+ by_true_id_c | pat_id_d | | |
+ to_true_id_c | pat_id_d | | |
Indexes:
- "dw1_notfs_id__p" PRIMARY KEY, btree (site_id, notf_id)
- "dw1_ntfs_createdat_email_undecided__i" btree (created_at) WHERE email_status = 1
- "dw1_ntfs_emailid" btree (site_id, email_id)
- "dw1_ntfs_postid__i" btree (site_id, about_post_id_c) WHERE about_post_id_c IS NOT NULL
- "dw1_ntfs_seen_createdat__i" btree ((
-CASE
- WHEN seen_at IS NULL THEN created_at + '100 years'::interval
- ELSE created_at
-END) DESC)
+ "notfs_p_notfid" PRIMARY KEY, btree (site_id, notf_id)
"notfs_i_aboutcat_topat" btree (site_id, about_cat_id_c, to_user_id) WHERE about_cat_id_c IS NOT NULL
"notfs_i_aboutpage_topat" btree (site_id, about_page_id_str_c, to_user_id) WHERE about_page_id_str_c IS NOT NULL
"notfs_i_aboutpat_topat" btree (site_id, about_pat_id_c, to_user_id) WHERE about_pat_id_c IS NOT NULL
"notfs_i_aboutpost_patreltype_frompat_subtype" btree (site_id, about_post_id_c, action_type, by_user_id, action_sub_id) WHERE about_post_id_c IS NOT NULL
+ "notfs_i_aboutpostid" btree (site_id, about_post_id_c) WHERE about_post_id_c IS NOT NULL
"notfs_i_abouttag_topat" btree (site_id, about_tag_id_c, to_user_id) WHERE about_tag_id_c IS NOT NULL
"notfs_i_aboutthingtype_subtype" btree (site_id, about_thing_type_c, about_sub_type_c) WHERE about_thing_type_c IS NOT NULL
"notfs_i_bypat" btree (site_id, by_user_id) WHERE by_user_id IS NOT NULL
- "notfs_touser_createdat__i" btree (site_id, to_user_id, created_at DESC)
- "notfs_touser_post__i" btree (site_id, to_user_id, about_post_id_c)
+ "notfs_i_bytrueid" btree (site_id, by_true_id_c) WHERE by_true_id_c IS NOT NULL
+ "notfs_i_createdat_but_unseen_first" btree ((
+CASE
+ WHEN seen_at IS NULL THEN created_at + '100 years'::interval
+ ELSE created_at
+END) DESC)
+ "notfs_i_createdat_if_undecided" btree (created_at) WHERE email_status = 1
+ "notfs_i_emailid" btree (site_id, email_id)
+ "notfs_i_totrueid_createdat" btree (site_id, to_true_id_c, created_at DESC) WHERE to_true_id_c IS NOT NULL
+ "notfs_i_touserid_aboutpostid" btree (site_id, to_user_id, about_post_id_c)
+ "notfs_i_touserid_createdat" btree (site_id, to_user_id, created_at DESC)
Check constraints:
"dw1_notfs_emailstatus__c_in" CHECK (email_status >= 1 AND email_status <= 20)
"dw1_notfs_seenat_ge_createdat__c" CHECK (seen_at > created_at)
"dw1_ntfs__c_action" CHECK ((action_type IS NOT NULL) = (action_sub_id IS NOT NULL))
"dw1_ntfs_by_to__c_ne" CHECK (by_user_id::text <> to_user_id::text)
"notfs_c_aboutthingtype_subtype_null" CHECK ((about_thing_type_c IS NULL) = (about_sub_type_c IS NULL))
+ "notfs_c_byuserid_ne_bytrueid" CHECK (by_user_id <> by_true_id_c::integer)
"notfs_c_notftype_range" CHECK (notf_type >= 101 AND notf_type <= 999)
+ "notfs_c_touserid_ne_totrueid" CHECK (to_user_id <> to_true_id_c::integer)
"notifications_c_id_not_for_imp" CHECK (notf_id < 2000000000)
Foreign-key constraints:
"notfs_aboutcat_r_cats" FOREIGN KEY (site_id, about_cat_id_c) REFERENCES categories3(site_id, id) DEFERRABLE
@@ -55,6 +61,8 @@ Foreign-key constraints:
"notfs_aboutpost_reltype_frompat_subtype_r_patrels" FOREIGN KEY (site_id, about_post_id_c, action_type, by_user_id, action_sub_id) REFERENCES post_actions3(site_id, to_post_id_c, rel_type_c, from_pat_id_c, sub_type_c) DEFERRABLE
"notfs_abouttag_r_tags" FOREIGN KEY (site_id, about_tag_id_c) REFERENCES tags_t(site_id_c, id_c) DEFERRABLE
"notfs_bypat_r_pats" FOREIGN KEY (site_id, by_user_id) REFERENCES users3(site_id, user_id) DEFERRABLE
+ "notfs_bytrueid_r_pats" FOREIGN KEY (site_id, by_true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
+ "notfs_totrueid_r_pats" FOREIGN KEY (site_id, to_true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
"ntfs_post_r_posts" FOREIGN KEY (site_id, about_post_id_c) REFERENCES posts3(site_id, unique_post_id) DEFERRABLE
"ntfs_r_emails" FOREIGN KEY (site_id, email_id) REFERENCES emails_out3(site_id, email_id_c) DEFERRABLE
"ntfs_r_sites" FOREIGN KEY (site_id) REFERENCES sites3(id) DEFERRABLE
diff --git a/docs/db-schema/post_actions3.txt b/docs/db-schema/post_actions3.txt
index f7cabf676d..64b42b3460 100644
--- a/docs/db-schema/post_actions3.txt
+++ b/docs/db-schema/post_actions3.txt
@@ -16,14 +16,16 @@
to_post_rev_nr_c | rev_nr_d | | |
dormant_status_c | dormant_status_d | | |
val_i32_c | i32_d | | |
- as_pat_id_c | pat_id_d | | |
+ from_true_id_c | pat_id_d | | |
added_by_id_c | member_id_d | | |
+ order_c | f32_d | | |
Indexes:
"dw2_postacs__p" PRIMARY KEY, btree (site_id, to_post_id_c, rel_type_c, from_pat_id_c, sub_type_c)
"dw2_postacs_deletedby__i" btree (site_id, deleted_by_id) WHERE deleted_by_id IS NOT NULL
- "dw2_postacs_page_byuser" btree (site_id, page_id, from_pat_id_c)
+ "patnoderels_i_pageid_frompatid" btree (site_id, page_id, from_pat_id_c)
+ "patnoderels_i_pageid_fromtrueid" btree (site_id, page_id, from_true_id_c) WHERE from_true_id_c IS NOT NULL
"patnodesinrels_i_addedbyid" btree (site_id, added_by_id_c) WHERE added_by_id_c IS NOT NULL
- "patnodesinrels_i_aspatid" btree (site_id, as_pat_id_c) WHERE as_pat_id_c IS NOT NULL
+ "patnodesinrels_i_aspatid" btree (site_id, from_true_id_c) WHERE from_true_id_c IS NOT NULL
"patrels_i_frompat_reltype_addedat" btree (site_id, from_pat_id_c, rel_type_c, created_at DESC)
"patrels_i_frompat_reltype_addedat_0dormant" btree (site_id, from_pat_id_c, rel_type_c, created_at DESC) WHERE dormant_status_c IS NULL
Check constraints:
@@ -35,8 +37,8 @@ Check constraints:
"postactions_c_postnr_not_for_imp" CHECK (post_nr < 2000000000)
"postactions_c_subid_not_for_imp" CHECK (sub_type_c < 2000000000)
Foreign-key constraints:
+ "patnoderels_fromtrueid_r_pats" FOREIGN KEY (site_id, from_true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
"patnodesinrels_addedbyid_r_pats" FOREIGN KEY (site_id, added_by_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
- "patnodesinrels_aspatid_r_pats" FOREIGN KEY (site_id, as_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
"postacs_createdby_r_people" FOREIGN KEY (site_id, from_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
"postacs_deletedby_r_people" FOREIGN KEY (site_id, deleted_by_id) REFERENCES users3(site_id, user_id) DEFERRABLE
"postacs_r_posts" FOREIGN KEY (site_id, to_post_id_c) REFERENCES posts3(site_id, unique_post_id) DEFERRABLE
diff --git a/docs/db-schema/posts3.txt b/docs/db-schema/posts3.txt
index 120ba39963..1b3ea9781d 100644
--- a/docs/db-schema/posts3.txt
+++ b/docs/db-schema/posts3.txt
@@ -65,10 +65,12 @@
index_prio_c | index_prio_d | | |
private_status_c | private_status_d | | |
creator_status_c | creator_status_d | | |
+ order_c | f32_d | | |
Indexes:
"dw2_posts_id__p" PRIMARY KEY, btree (site_id, unique_post_id)
"dw2_posts_page_postnr__u" UNIQUE, btree (site_id, page_id, post_nr)
"posts_u_extid" UNIQUE, btree (site_id, ext_id)
+ "posts_u_patid_pageid_parentnr_if_bookmark_0deld" UNIQUE, btree (site_id, created_by_id, page_id, parent_nr) WHERE type::smallint = 51 AND deleted_status = 0
"dw2_posts_approvedbyid__i" btree (site_id, approved_by_id) WHERE approved_by_id IS NOT NULL
"dw2_posts_closedbyid__i" btree (site_id, closed_by_id) WHERE closed_by_id IS NOT NULL
"dw2_posts_collapsedbyid__i" btree (site_id, collapsed_by_id) WHERE collapsed_by_id IS NOT NULL
@@ -84,6 +86,8 @@ Indexes:
"posts_i_createdat_id" btree (site_id, created_at DESC, unique_post_id DESC)
"posts_i_createdby_createdat_id" btree (site_id, created_by_id, created_at DESC, unique_post_id DESC)
"posts_i_lastapprovedat_0deld" btree (site_id, GREATEST(approved_at, last_approved_edit_at)) WHERE approved_at IS NOT NULL AND deleted_status = 0
+ "posts_i_patid_createdat_postid_if_bookmark_0deld" btree (site_id, created_by_id, created_at, unique_post_id) WHERE type::smallint = 51 AND deleted_status = 0
+ "posts_i_patid_createdat_postid_if_bookmark_deld" btree (site_id, created_by_id, created_at, unique_post_id) WHERE type::smallint = 51 AND deleted_status <> 0
"posts_i_privatepatsid" btree (site_id, private_pats_id_c) WHERE private_pats_id_c IS NOT NULL
Check constraints:
"dw2_posts__c_approved" CHECK ((approved_rev_nr IS NULL) = (approved_at IS NULL) AND (approved_rev_nr IS NULL) = (approved_by_id IS NULL) AND (approved_rev_nr IS NULL) = (approved_source IS NULL))
@@ -111,11 +115,15 @@ Check constraints:
"dw2_posts_multireply__c_num" CHECK (multireply::text ~ '^([0-9]+,)*[0-9]+$'::text)
"dw2_posts_parent__c_not_title" CHECK (parent_nr <> 0)
"posts__c_first_rev_by_creator" CHECK (curr_rev_by_id = created_by_id OR curr_rev_nr > 1)
+ "posts_c_bookmark_neg_nr" CHECK (type::smallint <> 51 OR post_nr <= '-1001'::integer)
+ "posts_c_bookmark_not_appr" CHECK (type::smallint <> 51 OR approved_at IS NULL)
"posts_c_currevnr_not_for_imp" CHECK (curr_rev_nr < 2000000000)
"posts_c_extid_ok" CHECK (is_valid_ext_id(ext_id))
"posts_c_id_not_for_imp" CHECK (unique_post_id < 2000000000)
"posts_c_nr_not_for_imp" CHECK (post_nr < 2000000000)
+ "posts_c_order_only_bookmarks_for_now" CHECK (order_c IS NULL OR type::smallint = 51)
"posts_c_parentnr_not_for_imp" CHECK (parent_nr < 2000000000)
+ "posts_c_privatecomt_neg_nr" CHECK (private_status_c IS NULL OR post_nr <= '-1001'::integer)
Foreign-key constraints:
"posts_approvedby_r_people" FOREIGN KEY (site_id, approved_by_id) REFERENCES users3(site_id, user_id) DEFERRABLE
"posts_closedby_r_people" FOREIGN KEY (site_id, closed_by_id) REFERENCES users3(site_id, user_id) DEFERRABLE
diff --git a/docs/db-schema/settings3.txt b/docs/db-schema/settings3.txt
index cd4a0a8040..66aa9f67c0 100644
--- a/docs/db-schema/settings3.txt
+++ b/docs/db-schema/settings3.txt
@@ -130,6 +130,8 @@
ai_conf_c | jsonb_ste16000_d | | |
enable_online_status_c | boolean | | |
follow_links_to_c | text_nonempty_ste2000_d | | |
+ own_domains_c | text_nonempty_ste2000_d | | |
+ authn_diag_conf_c | jsonb_ste8000_d | | |
Indexes:
"settings3_site_category" UNIQUE, btree (site_id, category_id) WHERE category_id IS NOT NULL
"settings3_site_page" UNIQUE, btree (site_id, page_id) WHERE page_id IS NOT NULL
diff --git a/docs/db-schema/tags_t.txt b/docs/db-schema/tags_t.txt
index a83a6c9f39..d4f5393703 100644
--- a/docs/db-schema/tags_t.txt
+++ b/docs/db-schema/tags_t.txt
@@ -14,7 +14,7 @@
val_url_c | http_url_ste_250_d | | |
val_jsonb_c | jsonb_ste1000_d | | |
val_i32_b_c | i32_d | | |
- val_f64_b_c | i32_d | | |
+ val_f64_b_c | f64_d | | |
Indexes:
"tags_p_id" PRIMARY KEY, btree (site_id_c, id_c)
"tags_u_id_patid" UNIQUE CONSTRAINT, btree (site_id_c, id_c, on_pat_id_c)
diff --git a/docs/db-schema/users3.txt b/docs/db-schema/users3.txt
index 47c24b4ce8..272315f773 100644
--- a/docs/db-schema/users3.txt
+++ b/docs/db-schema/users3.txt
@@ -219,6 +219,8 @@ Referenced by:
TABLE "links_t" CONSTRAINT "links_toppid_r_pps" FOREIGN KEY (site_id_c, to_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
TABLE "notifications3" CONSTRAINT "notfs_aboutpat_r_pats" FOREIGN KEY (site_id, about_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
TABLE "notifications3" CONSTRAINT "notfs_bypat_r_pats" FOREIGN KEY (site_id, by_user_id) REFERENCES users3(site_id, user_id) DEFERRABLE
+ TABLE "notifications3" CONSTRAINT "notfs_bytrueid_r_pats" FOREIGN KEY (site_id, by_true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
+ TABLE "notifications3" CONSTRAINT "notfs_totrueid_r_pats" FOREIGN KEY (site_id, to_true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
TABLE "notices_t" CONSTRAINT "notices_r_pats" FOREIGN KEY (site_id_c, to_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
TABLE "notifications3" CONSTRAINT "ntfs_touser_r_people" FOREIGN KEY (site_id, to_user_id) REFERENCES users3(site_id, user_id) DEFERRABLE
TABLE "page_notf_prefs_t" CONSTRAINT "pagenotfprefs_r_people" FOREIGN KEY (site_id, pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
@@ -244,8 +246,8 @@ Referenced by:
TABLE "page_users3" CONSTRAINT "pageusers_joinedby_r_people" FOREIGN KEY (site_id, joined_by_id) REFERENCES users3(site_id, user_id) DEFERRABLE
TABLE "page_users3" CONSTRAINT "pageusers_kickedby_r_people" FOREIGN KEY (site_id, kicked_by_id) REFERENCES users3(site_id, user_id) DEFERRABLE
TABLE "page_users3" CONSTRAINT "pageusers_user_r_people" FOREIGN KEY (site_id, user_id) REFERENCES users3(site_id, user_id) DEFERRABLE
+ TABLE "post_actions3" CONSTRAINT "patnoderels_fromtrueid_r_pats" FOREIGN KEY (site_id, from_true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
TABLE "post_actions3" CONSTRAINT "patnodesinrels_addedbyid_r_pats" FOREIGN KEY (site_id, added_by_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
- TABLE "post_actions3" CONSTRAINT "patnodesinrels_aspatid_r_pats" FOREIGN KEY (site_id, as_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
TABLE "users3" CONSTRAINT "pats_trueid_r_pats" FOREIGN KEY (site_id, true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
TABLE "perms_on_pages3" CONSTRAINT "permsonpages_r_people" FOREIGN KEY (site_id, for_people_id) REFERENCES users3(site_id, user_id) DEFERRABLE
TABLE "post_actions3" CONSTRAINT "postacs_createdby_r_people" FOREIGN KEY (site_id, from_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE
diff --git a/docs/maybe-do-later.txt b/docs/maybe-do-later.txt
index 374f459aba..8b8ff18b04 100644
--- a/docs/maybe-do-later.txt
+++ b/docs/maybe-do-later.txt
@@ -142,6 +142,7 @@ regardless of how they inherited permissions from other groups.
[granular_perms]
E.g. may-see-others'-sessions. Currently only amdins may, but sometimes, good if mods could too?
+Also see: [wiki_perms]
[tags]
Nice props / tags in Gerrit: https://chromium-review.googlesource.com/c/v8/v8/+/2537690
@@ -214,6 +215,7 @@ Can start at 21 = lightweight mod, 22 = mod, 23 = admin, 24 = owner?
Should there be a move-page-perm, not just alter-page?
[power_mod]
+[power_users]
[inherit_group_priv_prefs]
One should inherit privacy and noise settings from one's closest trust level group,
diff --git a/docs/tests-map.txt b/docs/tests-map.txt
index 005575bdf0..8bb5f2501f 100644
--- a/docs/tests-map.txt
+++ b/docs/tests-map.txt
@@ -298,22 +298,83 @@ anonymous comments,
anons:
partly impl:
- tests/app/debiki/dao/AnonymAppSpec.scala
+ Make work again, and code review:
+ - tests/e2e-wdio7/specs/alias-anons-basic.2br.f.e2e.ts
+ - tests/e2e-wdio7/specs/alias-anons-true-mixed.2br.f.e2e.ts
TESTS_MISSING:
+ - See ../wip/aliases/auto-test-thoughts.txt
+ - Anon mode, send priv msg?
+ - Callers of checkAliasOrThrowForbidden() & getAliasOrTruePat() [misc_alias_tests]
- Anons for drafts, UNTSETED.
- Load anon drafts, restore as anon. TyTANONDFLOAD
- Cannot move anon post to other page. TyTMOVANONCOMT
- Notfs from anons (comments, votes, more?), don't include real user name TyTNOTFFROMANON
- - UNTESTED; TESTS_MISSING // exp imp anons? True ids are incl in json dumps?
- - Different anon statuses: Comts start anon / NeverAlways.AlwaysButCanContinue / etc
+ - UNTESTED; TESTS_MISSING // exp imp anons?
+ True ids are incl in json dumps? [export_privid]
+ - Can't vote many times using different personas TyTALIVOTES
+ - Reuse anons: At most one per page & person [one_anon_per_page].
+ Verify cannot create many, by opening many browser tabs
+ & posting one anon comment in each?
- Edit & update the draft. TyTEDUPDDFT04
- Save anon draft, reuse an anon. TyTDFTREUSEANON.
+ - Save draft using the wrong alias — should work, but needs to choose an
+ ok alias before actually posting (not just saving a draft).
+ - When editing own post TyTDRAFTALI
+ - When editing sbd else's post
- Edit comment as anon: Anon name in new edit revision, & email notfs. TyTANONEDIT
- Post pages as anon
- - Accept/unaccept answer
- - Close/reopen page
- - Delete/undelete own page
- - Rename page
- - Change page status PageTitleSettingsController
+ - Post page as anon,
+ edit as self
+ alter as self
+ - Post page as self
+ edit as anon
+ alter as anon
+ - Post comment as anon
+ edit as self
+ alter as self
+ - Post comment as self
+ edit as anon
+ alter as anon
+ - Change own anon post to wiki & back
+ others' not/anon post to wiki & back as anon / not-anon oneself
+ - Edit other's page text
+ - Alter other's page TyTALIALTERPG, PageTitleSettingsController
+ - Alter =
+ - Accept/unaccept answer
+ - Close/reopen page
+ - Delete/undelete own anon page
+ - Delete/undelete own anon comment
+ - Rename page
+ - Change page type, doing status, answer
+ - Move to other category, where:
+ - Alias allowed
+ - Alias not allowed
+ - Start composing new topic, change to/from anon cat, see Post As ...
+ appear/disappear, and "You cannot be anonymous ..." etc info diags pop up.
+ - Reply to oneself on anon-rec page where's been anon elsewhere —> self, etc
+ - Reply anonly, go to sbd's profile page, compose priv msg: Should become oneself
+ - Anon-by-def page, start composig a reply (as anon), cancel, go to sbd's profile page,
+ compose priv msg: Should now post as oneself (not anon any more).
+ - Edit history: Proper names (e.g. Anon, not one's true name) shown?
+ - Comments
+ - Page title
+ - Orig post
+ - Different anon statuses: Comts start anon / NeverAlways.AlwaysButCanContinue / etc
+ - Page with both Perm Anon and Temp Anon comments: [dif_anon_status]
+ - Like-vote OP, gets to choose: Perm Anon or Temp Anon, or Self
+ - Temp Anon in temp anon sub thread
+ - Perm Anon in perm anon sub thread
+ - Can't move anon comments to other pages
+ - Persona indicator updated properly when: (lots of combinations!)
+ starting on anon never / allowed / recommened / always page,
+ - navigating to anon recommended cat, then to not-anon
+ - navigating to not-anon cat, then recommended cat, then not-anon
+ back to anon / not-anon page
+ - Anon posts enabled
+ - tests/app/debiki/dao/AnonymAppSpec.scala TyTANON0ENBL TyEM0MKANON_
+ - in one cat, not in another
+ - in sub cat
+ - on specific page
- ... and more
different users -
diff --git a/docs/tyworld.adoc b/docs/tyworld.adoc
index 56e881f2a3..d8f28fc9df 100644
--- a/docs/tyworld.adoc
+++ b/docs/tyworld.adoc
@@ -138,14 +138,23 @@ A new post gets a new id and can work as the new id for a new flag of the same t
== Anonyms and Pseudonyms
+Also see: ../wip/aliases/wip.txt. This section here in tyworld.adoc is out-of-date!
+
=== Anonyms posts
When you anonymously, then, a new anonym (anonymous user) is created, for you,
-to use on that particular page. You can't use it outside that page
+to use in the current discussion, on that specific page. You can't use it outside that page
(instead, another then gets created). Anon comments cannot be moved to
other pages, and aren't visible to others (only to yourself) in the activity
list on your user profile page, or in your posts counts. [list_anon_posts]
+Currently, you can have only one anonym persona per page [one_anon_per_page].
+(Actually, you can have more, if someone moves the page between categories that use
+different types of anonyms (temporary anonyms or permanent anonyms), and you post
+anonymous comments of each type. In such cases, you can have one anonym
+per page and anonym type (`AnonStatus`)) It'd be confusing if people could create many
+anonymous users and pretend to be many people, in a single discussion.
+
The software remembers if you want to be anonymous or not, per
discussion page, by looking at your previous comments in that discussion
and sub thread you're replying in — because maybe you're using using your
@@ -169,10 +178,11 @@ Categories can be 1) always-anonymous, or 2) anon by default, or
3) real account by default but anon posts allowed. Or 4) anon posts
not allowed (only real accounts). Edit: See `[NeverAlways]`.
-A category can be configured to get _de-anonymized_ after a while (!).
-That is, say two weeks after a new page has been posted in that category,
-the real usernames are shown so everyone can see who wrote what.
-
+Not implemented: If, when enabling anonymity in a category, the admin selects
+_Better ideas & decisions_ as the purpose (but not _Sensitive Discussions_), then,
+the category can be configured to get _de-anonymized_ after a while (!).
+For example, two weeks after a new page has been posted in that category,
+the real usernames get shown so everyone can see who wrote what.
In such categories, before posting, there's an obtrusive info box about this,
so everyone will know how it works.
@@ -209,8 +219,12 @@ your pseudonym's comments surprisingly often or infrequently — then, your pseu
could get a different reputation than your main account. Although you're
the same person. `[pseudonyms_trust]`
+Possibly, there'll be different types of pseudonyms. See
+../wip/aliases/wip.txt [pseudonym_types].
+
=== Tech notes
+Not impl, and this'll change:
Implementation wise, to show notifications from all one's pseudonyms,
Talkyard does one lookup per pseudonym. So that's why you cannot
have hundereds of pseudonyms (because then this'd be slow).
diff --git a/relchans/tyse-v0-dev b/relchans/tyse-v0-dev
index 9502168518..9b9f590da4 160000
--- a/relchans/tyse-v0-dev
+++ b/relchans/tyse-v0-dev
@@ -1 +1 @@
-Subproject commit 95021685184b96db8ebcca93f8b7e42dbf0547b2
+Subproject commit 9b9f590da473869c6bb9336b1226eaad545c23bc
diff --git a/s/run-e2e-tests.sh b/s/run-e2e-tests.sh
index 56ebd95233..56d41d6698 100755
--- a/s/run-e2e-tests.sh
+++ b/s/run-e2e-tests.sh
@@ -535,6 +535,13 @@ function runAllE2eTests {
$r s/wdio-7 --only may-see-email-adrs.2br.d --cd -i $args
+ # Aliases (anonyms, pseudonyms)
+ # ------------
+
+ #$r s/wdio-7 --only alias-anons-true-mixed.2br.f --cd -i $args
+ #$r s/wdio-7 --only alias-anons-basic.2br.f --cd -i $args
+
+
# API
# ------------
diff --git a/tests/app/debiki/dao/AnonymAppSpec.scala b/tests/app/debiki/dao/AnonymAppSpec.scala
index 3e9a73e943..a46ff1ea19 100644
--- a/tests/app/debiki/dao/AnonymAppSpec.scala
+++ b/tests/app/debiki/dao/AnonymAppSpec.scala
@@ -74,12 +74,30 @@ class AnonymAppSpec extends DaoAppSuite(
userTwoS1 = createPasswordUser("mm33ww77", site1.dao, trustLevel = TrustLevel.BasicMember)
}
- "Try post anonymously — but anon posts are disabled, by default" in {
+ "Try post anonymously — but anon posts not enabled, request rejected TyTANON0ENBL" in {
+ val dao = site1.dao
+ val ex = intercept[ResultException] {
+ createPage2(PageType.Discussion, dao.textAndHtmlMaker.forTitle("Anon Test"),
+ bodyTextAndHtml = dao.textAndHtmlMaker.forBodyOrComment("Test anon post."),
+ authorId = userOneS1.id, browserIdData, dao, anyCategoryId = Some(catA.id),
+ asAlias = Some(WhichAliasPat.LazyCreatedAnon(AnonStatus.IsAnonOnlySelfCanDeanon)))
+ }
+ ex.getMessage must include("TyEM0MKANON_")
+ }
+
+ "Enable anon posts" in {
+ val dao = site1.dao
+ editCategory(catA, createCatAResult.permissionsWithIds,
+ browserIdData, dao,
+ comtsStartAnon = Some(Some(NeverAlways.Recommended)))
+ }
+
+ "Try post anonymously — now anon posts are enabled" in {
val dao = site1.dao
createPageResult = createPage2(PageType.Discussion, dao.textAndHtmlMaker.forTitle("Anon Test"),
bodyTextAndHtml = dao.textAndHtmlMaker.forBodyOrComment("Test anon post."),
authorId = userOneS1.id, browserIdData, dao, anyCategoryId = Some(catA.id),
- doAsAnon = Some(WhichAnon.NewAnon(AnonStatus.IsAnonOnlySelfCanDeanon)))
+ asAlias = Some(WhichAliasPat.LazyCreatedAnon(AnonStatus.IsAnonOnlySelfCanDeanon)))
pageId = createPageResult.id
}
diff --git a/tests/app/debiki/dao/DaoAppSuite.scala b/tests/app/debiki/dao/DaoAppSuite.scala
index adf562d0f2..853c9e1785 100644
--- a/tests/app/debiki/dao/DaoAppSuite.scala
+++ b/tests/app/debiki/dao/DaoAppSuite.scala
@@ -348,19 +348,20 @@ class DaoAppSuite(
def editCategory(cat: Cat, permissions: ImmSeq[PermsOnPages],
browserIdData: BrowserIdData, dao: SiteDao,
newParentId: Opt[CatId] = None,
- newSectPageId: Opt[PageId] = None): Cat = {
+ newSectPageId: Opt[PageId] = None,
+ comtsStartAnon: Opt[Opt[NeverAlways]] = None,
+ ): Cat = {
var catToSave = CategoryToSave.initFrom(cat)
- newParentId map { parCatId =>
- catToSave = catToSave.copy(parentId = parCatId)
- }
- newSectPageId map { sectPageId =>
- catToSave = catToSave.copy(sectionPageId = sectPageId)
- }
+ catToSave = catToSave.copy(
+ parentId = newParentId.getOrElse(catToSave.parentId),
+ sectionPageId = newSectPageId.getOrElse(catToSave.sectionPageId),
+ comtsStartAnon = comtsStartAnon.getOrElse(catToSave.comtsStartAnon),
+ )
dao.editCategory(catToSave, permissions, who = Who.System)
}
- REMOVE; CLEAN_UP // use createPage2 instead, and rename it to createPage().
+ REMOVE; CLEAN_UP // use createPageSkipAuZ instead, and rename it to createPage().
def createPage(pageRole: PageType, titleTextAndHtml: TitleSourceAndHtml,
bodyTextAndHtml: TextAndHtml, authorId: UserId, browserIdData: BrowserIdData,
dao: SiteDao, anyCategoryId: Option[CategoryId] = None,
@@ -374,15 +375,14 @@ class DaoAppSuite(
def createPage2(pageRole: PageType, titleTextAndHtml: TitleSourceAndHtml,
bodyTextAndHtml: TextAndHtml, authorId: UserId, browserIdData: BrowserIdData,
dao: SiteDao, anyCategoryId: Option[CategoryId] = None,
- doAsAnon: Opt[WhichAnon.NewAnon] = None,
+ asAlias: Opt[WhichAliasPat.LazyCreatedAnon] = None,
extId: Option[ExtId] = None, discussionIds: Set[AltPageId] = Set.empty): CreatePageResult = {
- dao.createPage2(
+ dao.createPageSkipAuZ(
pageRole, PageStatus.Published, anyCategoryId = anyCategoryId, withTags = Nil,
anyFolder = Some("/"), anySlug = Some(""),
title = titleTextAndHtml, bodyTextAndHtml = bodyTextAndHtml,
showId = true, deleteDraftNr = None, Who(authorId, browserIdData), dummySpamRelReqStuff,
- doAsAnon = doAsAnon,
- discussionIds = discussionIds, extId = extId)
+ asAlias = asAlias, discussionIds = discussionIds, extId = extId)
}
@@ -391,7 +391,7 @@ class DaoAppSuite(
val textAndHtml =
if (skipNashorn) textAndHtmlMaker.testBody(text)
else textAndHtmlMaker.forBodyOrComment(text)
- dao.insertReply(textAndHtml, pageId,
+ dao.insertReplySkipAuZ(textAndHtml, pageId,
replyToPostNrs = Set(parentNr getOrElse PageParts.BodyNr), PostType.Normal, deleteDraftNr = None,
Who(TrueId(memberId), browserIdData), dummySpamRelReqStuff).post
}
diff --git a/tests/app/debiki/dao/DeletePageAppSpec.scala b/tests/app/debiki/dao/DeletePageAppSpec.scala
index d7991d271c..a934666808 100644
--- a/tests/app/debiki/dao/DeletePageAppSpec.scala
+++ b/tests/app/debiki/dao/DeletePageAppSpec.scala
@@ -56,7 +56,7 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr
// Delete all pages.
dao.deletePagesIfAuth(Seq(discussionId, forumId, htmlPageId),
- Who(admin.trueId2, browserIdData), undelete = false)
+ Who(admin.trueId2, browserIdData), asAlias = None, undelete = false)
// Verify marked as deleted.
dao.getPageMeta(discussionId).get.deletedAt mustBe defined
@@ -66,7 +66,7 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr
// Undelete, verify no longer marked as deleted.
dao.deletePagesIfAuth(Seq(discussionId, forumId, htmlPageId),
- Who(admin.trueId2, browserIdData), undelete = true)
+ Who(admin.trueId2, browserIdData), asAlias = None, undelete = true)
dao.getPageMeta(discussionId).get.deletedAt mustBe None
dao.getPageMeta(forumId).get.deletedAt mustBe None
dao.getPageMeta(htmlPageId).get.deletedAt mustBe None
@@ -79,7 +79,7 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr
for (pageId <- Seq(discussionId, htmlPageId, otherPageId)) {
intercept[ResultException] {
dao.deletePagesIfAuth(
- Seq(pageId), Who(moderator.trueId2, browserIdData), undelete = false)
+ Seq(pageId), Who(moderator.trueId2, browserIdData), None, undelete = false)
dao.getPageMeta(discussionId).get.deletedAt mustBe defined
}.getMessage must include("TyEM0SEEPG_")
}
@@ -90,7 +90,7 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr
"non-staff also may not delete pages they cannot see" in {
intercept[ResultException] {
dao.deletePagesIfAuth(
- Seq(discussionId), Who(user.trueId2, browserIdData), undelete = false)
+ Seq(discussionId), Who(user.trueId2, browserIdData), None, undelete = false)
}.getMessage must include("TyEM0SEEPG_")
}
@@ -113,34 +113,34 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr
"now mods can delete discussions — they may now see them" - {
"delete page" in {
dao.deletePagesIfAuth(
- Seq(discussionId), Who(moderator.trueId2, browserIdData), undelete = false)
+ Seq(discussionId), Who(moderator.trueId2, browserIdData), None, undelete = false)
dao.getPageMeta(discussionId).get.deletedAt mustBe defined
}
"undelete page" in {
dao.deletePagesIfAuth(
- Seq(discussionId), Who(moderator.trueId2, browserIdData), undelete = true)
+ Seq(discussionId), Who(moderator.trueId2, browserIdData), None, undelete = true)
dao.getPageMeta(discussionId).get.deletedAt mustBe None
}
"still cannot delete the *other* page, it's still not in the forum" in {
intercept[ResultException] {
dao.deletePagesIfAuth(
- Seq(otherPageId), Who(moderator.trueId2, browserIdData), undelete = false)
+ Seq(otherPageId), Who(moderator.trueId2, browserIdData), None, undelete = false)
}.getMessage must include("TyEM0SEEPG_")
}
"cannot delete forum" in {
intercept[ResultException] {
dao.deletePagesIfAuth(
- Seq(forumId), Who(moderator.trueId2, browserIdData), undelete = false)
+ Seq(forumId), Who(moderator.trueId2, browserIdData), None, undelete = false)
}.getMessage must include("EsE5GKF23_")
}
"cannot delete custom html page" in {
intercept[ResultException] {
dao.deletePagesIfAuth(
- Seq(htmlPageId), Who(moderator.trueId2, browserIdData), undelete = false)
+ Seq(htmlPageId), Who(moderator.trueId2, browserIdData), None, undelete = false)
}.getMessage must include("EsE5GKF23_")
}
}
@@ -148,14 +148,15 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr
"do nothing if page doesn't exist" in {
val admin = createPasswordOwner(s"dltr_adm2", dao)
val badPageId = "zzwwffpp"
- dao.deletePagesIfAuth(Seq(badPageId), Who(admin.trueId2, browserIdData), undelete = false)
+ dao.deletePagesIfAuth(
+ Seq(badPageId), Who(admin.trueId2, browserIdData), None, undelete = false)
dao.getPageMeta(badPageId) mustBe None
}
"non-staff users still cannot see the pages — they're in the staff cat" in {
intercept[ResultException] {
dao.deletePagesIfAuth(
- Seq(discussionId), Who(user.trueId2, browserIdData), undelete = false)
+ Seq(discussionId), Who(user.trueId2, browserIdData), None, undelete = false)
}.getMessage must include("TyEM0SEEPG_")
}
@@ -166,7 +167,7 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr
"non-staff can now see it — but still may not delete it" in {
intercept[ResultException] {
dao.deletePagesIfAuth(
- Seq(discussionId), Who(user.trueId2, browserIdData), undelete = false)
+ Seq(discussionId), Who(user.trueId2, browserIdData), None, undelete = false)
}.getMessage must include("TyEDELOTRSPG_")
}
diff --git a/tests/app/debiki/dao/DraftsDaoAppSpec.scala b/tests/app/debiki/dao/DraftsDaoAppSpec.scala
index dd4bad6053..90b1f7af5e 100644
--- a/tests/app/debiki/dao/DraftsDaoAppSpec.scala
+++ b/tests/app/debiki/dao/DraftsDaoAppSpec.scala
@@ -416,7 +416,7 @@ class DraftsDaoAppSpec extends DaoAppSuite(disableScripts = true, disableBackgro
createdAt = now,
forWhat = locator,
postType = Some(PostType.Normal),
- doAsAnon = Some(WhichAnon.NewAnon(AnonStatus.IsAnonCanAutoDeanon)),
+ doAsAnon = Some(WhichAliasId.LazyCreatedAnon(AnonStatus.IsAnonCanAutoDeanon)),
title = "",
text = DraftSixAnonReplyText)
diff --git a/tests/app/debiki/dao/MovePostsAppSpec.scala b/tests/app/debiki/dao/MovePostsAppSpec.scala
index 83b6723bb3..3f28a1e0f9 100644
--- a/tests/app/debiki/dao/MovePostsAppSpec.scala
+++ b/tests/app/debiki/dao/MovePostsAppSpec.scala
@@ -188,7 +188,7 @@ class MovePostsAppSpec extends DaoAppSuite(disableScripts = true, disableBackgro
val pageTwoId = createPage(PageType.Discussion, textAndHtmlMaker.testTitle("Page Two"),
textAndHtmlMaker.testBody("Body two."), SystemUserId, browserIdData, dao)
- val postOnPageTwo = dao.insertReply(textAndHtmlMaker.testBody("Post on page 2."), pageTwoId,
+ val postOnPageTwo = dao.insertReplySkipAuZ(textAndHtmlMaker.testBody("Post on page 2."), pageTwoId,
replyToPostNrs = Set(PageParts.BodyNr), PostType.Normal, deleteDraftNr = None,
Who(SystemUserId, browserIdData = browserIdData), dummySpamRelReqStuff).post
@@ -256,7 +256,7 @@ class MovePostsAppSpec extends DaoAppSuite(disableScripts = true, disableBackgro
val pageTwoId = createPage(PageType.Discussion, textAndHtmlMaker.testTitle("Page Two"),
textAndHtmlMaker.testBody("Body two."), SystemUserId, browserIdData, dao)
- val postOnPageTwo = dao.insertReply(textAndHtmlMaker.testBody("Post on page 2."), pageTwoId,
+ val postOnPageTwo = dao.insertReplySkipAuZ(textAndHtmlMaker.testBody("Post on page 2."), pageTwoId,
replyToPostNrs = Set(PageParts.BodyNr), PostType.Normal, deleteDraftNr = None,
Who(SystemUserId, browserIdData = browserIdData), dummySpamRelReqStuff).post
@@ -299,7 +299,7 @@ class MovePostsAppSpec extends DaoAppSuite(disableScripts = true, disableBackgro
lastReplyOrigPage.nr mustBe (otherPost.nr + 1)
info("can add replies to the new page")
- val lastPostPageTwo = dao.insertReply(textAndHtmlMaker.testBody("Last post, page 2."), pageTwoId,
+ val lastPostPageTwo = dao.insertReplySkipAuZ(textAndHtmlMaker.testBody("Last post, page 2."), pageTwoId,
replyToPostNrs = Set(maxNewNr), PostType.Normal, deleteDraftNr = None,
Who(SystemUserId, browserIdData),
dummySpamRelReqStuff).post
@@ -316,7 +316,7 @@ class MovePostsAppSpec extends DaoAppSuite(disableScripts = true, disableBackgro
val pageTwoId = createPage(PageType.Discussion, textAndHtmlMaker.testTitle("Page Two"),
textAndHtmlMaker.testBody("Body two."), SystemUserId, browserIdData, dao)
- val postOnPageTwo = dao.insertReply(textAndHtmlMaker.testBody("Post on page 2."), pageTwoId,
+ val postOnPageTwo = dao.insertReplySkipAuZ(textAndHtmlMaker.testBody("Post on page 2."), pageTwoId,
replyToPostNrs = Set(PageParts.BodyNr), PostType.Normal, deleteDraftNr = None,
Who(SystemUserId, browserIdData = browserIdData), dummySpamRelReqStuff).post
diff --git a/tests/app/debiki/dao/ReviewStuffAppSuite.scala b/tests/app/debiki/dao/ReviewStuffAppSuite.scala
index d34a9bec50..73577cd785 100644
--- a/tests/app/debiki/dao/ReviewStuffAppSuite.scala
+++ b/tests/app/debiki/dao/ReviewStuffAppSuite.scala
@@ -57,7 +57,7 @@ class ReviewStuffAppSuite(randomString: String)
def testAdminsRepliesApproved(adminId: UserId, pageId: PageId) {
for (i <- 1 to 10) {
- val result = dao.insertReply(textAndHtmlMaker.testBody(s"reply_9032372 $r, i = $i"), pageId,
+ val result = dao.insertReplySkipAuZ(textAndHtmlMaker.testBody(s"reply_9032372 $r, i = $i"), pageId,
replyToPostNrs = Set(PageParts.BodyNr), PostType.Normal, deleteDraftNr = None,
Who(adminId, browserIdData), dummySpamRelReqStuff)
result.post.isCurrentVersionApproved mustBe true
@@ -66,7 +66,7 @@ class ReviewStuffAppSuite(randomString: String)
}
def reply(memberId: UserId, text: String): InsertPostResult = {
- dao.insertReply(textAndHtmlMaker.testBody(text), thePageId,
+ dao.insertReplySkipAuZ(textAndHtmlMaker.testBody(text), thePageId,
replyToPostNrs = Set(PageParts.BodyNr), PostType.Normal, deleteDraftNr = None,
Who(memberId, browserIdData), dummySpamRelReqStuff)
}
diff --git a/tests/app/talkyard/server/links/LinksAppSpec.scala b/tests/app/talkyard/server/links/LinksAppSpec.scala
index a8138b68af..382c314735 100644
--- a/tests/app/talkyard/server/links/LinksAppSpec.scala
+++ b/tests/app/talkyard/server/links/LinksAppSpec.scala
@@ -401,7 +401,7 @@ class LinksAppSpec extends DaoAppSuite {
"Links from deleted *pages* are ignored TyT7RD3LM5" - {
"Delete page A".inWriteTx(daoSite1) { (tx, staleStuff) =>
daoSite1.deletePagesImpl(
- Seq(pageA.id), systemWho)(tx, staleStuff)
+ Seq(pageA.id), systemWho, asAlias = None)(tx, staleStuff)
}
"Now only pages B, C and D links to Z".inReadTx(daoSite1) { tx =>
@@ -433,7 +433,7 @@ class LinksAppSpec extends DaoAppSuite {
"Undelete page A".inWriteTx(daoSite1) { (tx, staleStuff) =>
daoSite1.deletePagesImpl(
- Seq(pageA.id), systemWho, undelete = true)(tx, staleStuff)
+ Seq(pageA.id), systemWho, asAlias = None, undelete = true)(tx, staleStuff)
}
"Undelete category".inWriteTx(daoSite1) { (tx, staleStuff) =>
@@ -467,7 +467,7 @@ class LinksAppSpec extends DaoAppSuite {
"Delete page Z".inWriteTx(daoSite1) { (tx, staleStuff) =>
daoSite1.deletePagesImpl(
- Seq(pageZ.id), systemWho, undelete = true)(tx, staleStuff)
+ Seq(pageZ.id), systemWho, asAlias = None, undelete = true)(tx, staleStuff)
}
"Can find links to deleted page Z".inReadTx(daoSite1) { tx =>
diff --git a/tests/e2e-wdio7/package.json b/tests/e2e-wdio7/package.json
index 7c72296a88..fb14c395a1 100644
--- a/tests/e2e-wdio7/package.json
+++ b/tests/e2e-wdio7/package.json
@@ -14,7 +14,7 @@
"@wdio/spec-reporter": "^7.20.3",
"@wdio/types": "^7.20.3",
"axios": "^0.26.1",
- "chromedriver": "^126.0.3",
+ "chromedriver": "^128.0.0",
"paseto.js": "^0.1.7",
"ts-node": "^10.9.1",
"wdio-chromedriver-service": "^7.3.2"
diff --git a/tests/e2e-wdio7/specs/page-type-problem-statuses.2br.d.e2e.ts b/tests/e2e-wdio7/specs/page-type-problem-statuses.2br.d.e2e.ts
index fc1194acbd..d0bbbc52ab 100644
--- a/tests/e2e-wdio7/specs/page-type-problem-statuses.2br.d.e2e.ts
+++ b/tests/e2e-wdio7/specs/page-type-problem-statuses.2br.d.e2e.ts
@@ -28,7 +28,7 @@ const mariasOpReplyReply = 'mariasOpReplyReply';
// .Last_status_change post nr will be 5, and the first answer will be 5 + 1:
const okaySolutionPostNr = 6;
-const optimalSolutionPostNr = 7;
+const optimalSolutionPostNr = 6 + 2;
describe("page-type-problem-statuses.2br.d TyT602AKK73", () => {
@@ -139,10 +139,13 @@ describe("page-type-problem-statuses.2br.d TyT602AKK73", () => {
});
it(`But Maria can — it's her page. She selects her comment as the solution`, async () => {
+ // Generates meta comment nr = okaySolutionPostNr + 1: "@maria accepted an answer"
await mariasBrowser.topic.selectPostNrAsAnswer(okaySolutionPostNr);
});
it(`Maria posts an even better solution`, async () => {
+ // Becomes nr = okaySolutionPostNr + 2.
+ assert.eq(optimalSolutionPostNr, okaySolutionPostNr + 2);
await mariasBrowser.complex.replyToOrigPost(`Do it three times`)
});
@@ -188,7 +191,6 @@ describe("page-type-problem-statuses.2br.d TyT602AKK73", () => {
});
it(`Corax, wisely, selects as solution Maria's best comment TyTCORECAN`, async () => {
- assert.eq(optimalSolutionPostNr, okaySolutionPostNr + 1);
await corax_brB.topic.selectPostNrAsAnswer(optimalSolutionPostNr);
});
diff --git a/tests/e2e-wdio7/specs/page-type-question-closed.2br.d.e2e.ts b/tests/e2e-wdio7/specs/page-type-question-closed.2br.d.e2e.ts
index 3e049ca5f3..1eed088f23 100644
--- a/tests/e2e-wdio7/specs/page-type-question-closed.2br.d.e2e.ts
+++ b/tests/e2e-wdio7/specs/page-type-question-closed.2br.d.e2e.ts
@@ -27,8 +27,9 @@ let mariasTopicUrl: string;
const catAnserNr = c.FirstReplyNr;
const otterAnserNr = c.SecondReplyNr;
-const closeEventPostNr = 4;
-const reopenEventPostNr = 7;
+const selectAnswerPostNr = 4;
+const closeEventPostNr = selectAnswerPostNr + 6; // = 10
+const reopenEventPostNr = closeEventPostNr + 3; // = 13
describe(`page-type-question-closed.2br.d TyTPATYQUESTCLOSD`, () => {
@@ -91,20 +92,21 @@ describe(`page-type-question-closed.2br.d TyTPATYQUESTCLOSD`, () => {
await mariasBrowser.refresh2();
await mariasBrowser.topic.waitForPostNrVisible(catAnserNr);
assert.ok(await mariasBrowser.topic.canSelectAnswer()); // (2PR5PH)
- await mariasBrowser.topic.selectPostNrAsAnswer(2); // a cat
+ // Meta comment, nr 4 == selectAnswerPostNr
+ await mariasBrowser.topic.selectPostNrAsAnswer(catAnserNr); // a cat
});
it("... unselects", async () => {
- await mariasBrowser.topic.unselectPostNrAsAnswer(2); // no cat
+ await mariasBrowser.topic.unselectPostNrAsAnswer(catAnserNr); // nr 5
});
it("... selects another", async () => {
- await mariasBrowser.topic.selectPostNrAsAnswer(3); // an otter
+ await mariasBrowser.topic.selectPostNrAsAnswer(otterAnserNr); // nr 6
});
it("... unselects it, selects it again", async () => {
- await mariasBrowser.topic.unselectPostNrAsAnswer(3);
- await mariasBrowser.topic.selectPostNrAsAnswer(3);
+ await mariasBrowser.topic.unselectPostNrAsAnswer(otterAnserNr); // nr 7
+ await mariasBrowser.topic.selectPostNrAsAnswer(otterAnserNr); // nr 8
});
it("She can click the check mark icon next to the title, to view the answer", async () => {
@@ -118,8 +120,16 @@ describe(`page-type-question-closed.2br.d TyTPATYQUESTCLOSD`, () => {
await owensBrowser.complex.loginWithPasswordViaTopbar(owen);
});
+ it("... sees 5 select or unselect answer meta comments", async () => {
+ assert.that(await owensBrowser.topic.isPostNrVisible(selectAnswerPostNr));
+ assert.that(await owensBrowser.topic.isPostNrVisible(selectAnswerPostNr + 1));
+ assert.that(await owensBrowser.topic.isPostNrVisible(selectAnswerPostNr + 2));
+ assert.that(await owensBrowser.topic.isPostNrVisible(selectAnswerPostNr + 3));
+ assert.that(await owensBrowser.topic.isPostNrVisible(selectAnswerPostNr + 4));
+ });
+
it("... and unselects the answer", async () => {
- await owensBrowser.topic.unselectPostNrAsAnswer(otterAnserNr);
+ await owensBrowser.topic.unselectPostNrAsAnswer(otterAnserNr); // nr 9
});
it("... and closes the topic", async () => {
@@ -130,54 +140,69 @@ describe(`page-type-question-closed.2br.d TyTPATYQUESTCLOSD`, () => {
it("Maria wants to select Otter as answer again, but she cannot (page closed)", async () => {
await mariasBrowser.topic.refreshUntilPostNrAppears(closeEventPostNr, { isMetaPost: true });
await mariasBrowser.topic.waitForPostNrVisible(c.FirstReplyNr);
+ assert.not(await owensBrowser.topic.isPostNrVisible(closeEventPostNr + 1)); // ttt
assert.not(await mariasBrowser.topic.canSelectAnswer()); // (2PR5PH)
});
it("... instead she replies", async () => {
- await mariasBrowser.complex.replyToPostNr(3, "Thanks! Such a good idea"); // becomes post nr 5
+ await mariasBrowser.complex.replyToPostNr(otterAnserNr, "Thanks! Such a good idea"); // nr 11
});
it("... and post a progress reply", async () => {
- await mariasBrowser.complex.addProgressReply("Thanks everyone! An otter then, a bath tube, and fish.")
+ await mariasBrowser.complex.addProgressReply(
+ "Thanks everyone! An otter then, a bath tube, and fish.") // nr 12
});
it("Owen reopens the topic", async () => {
assert.not(await owensBrowser.topic.isPostNrVisible(reopenEventPostNr)); // ttt
- await owensBrowser.topic.reopenTopic(); // generates post nr 7 = reopenEventPostNr
+ await owensBrowser.topic.reopenTopic(); // generates post nr `reopenEventPostNr` nr 13
});
it("Now Maria can select Otter — but oh no! She accidentally selects Cat", async () => {
await mariasBrowser.refresh2();
- await mariasBrowser.topic.selectPostNrAsAnswer(catAnserNr);
+ await mariasBrowser.topic.selectPostNrAsAnswer(catAnserNr); // nr 14
});
it("... Currently needs to refres for all posts to appear", async () => {
- await mariasBrowser.topic.refreshUntilPostNrAppears(7, { isMetaPost: true });
- // There's no post nr 8 (accepting & unaccepting answers, don't currently
- // generate meta posts).
- assert.not(await owensBrowser.topic.isPostNrVisible(8)); // ttt
+ await mariasBrowser.topic.refreshUntilPostNrAppears(reopenEventPostNr + 1, { isMetaPost: true });
+ // There's no post nr `reopenEventPostNr + 2`.
+ assert.not(await owensBrowser.topic.isPostNrVisible(reopenEventPostNr + 2)); // ttt
});
it("Everything is in the correct order", async () => {
await mariasBrowser.topic.waitForPostAssertTextMatches(1, "a cat or an otter?");
await mariasBrowser.topic.assertPostTextMatches(2, "Yes, a cat");
await mariasBrowser.topic.assertPostTextMatches(3, "Yes, an otter");
- assert.eq(closeEventPostNr, 4);
- await mariasBrowser.topic.assertMetaPostTextMatches(4, "closed");
- await mariasBrowser.topic.assertPostTextMatches(5, "good idea");
- await mariasBrowser.topic.assertPostTextMatches(6, "Thanks everyone!");
- assert.eq(reopenEventPostNr, 7);
- await mariasBrowser.topic.assertMetaPostTextMatches(7, "reopened");
+ await mariasBrowser.topic.assertMetaPostTextMatches(selectAnswerPostNr, /^accepted an answer/);
+ await mariasBrowser.topic.assertMetaPostTextMatches(selectAnswerPostNr + 1, "unaccepted an answer");
+ await mariasBrowser.topic.assertMetaPostTextMatches(closeEventPostNr, "closed"); // nr 10
+ await mariasBrowser.topic.assertPostTextMatches(closeEventPostNr + 1, "good idea"); // nr 11
+ await mariasBrowser.topic.assertPostTextMatches(closeEventPostNr + 2, "Thanks everyone!"); // nr 12
+
+ await mariasBrowser.topic.assertMetaPostTextMatches(reopenEventPostNr, "reopened");
+ });
+
+ it("Everything is in the correct order", async () => {
await mariasBrowser.topic.assertPostOrderIs([
c.TitleNr,
c.BodyNr,
2, // cat
3, // otter
- 5, // `——— the "Good idea" reply
- 4, // the topic-closed event
- 6, // the "Thanks everyone" comment
- 7]); // the topic-reopened event
+ 11, // `——— the "Good idea" reply
+
+ 4, // selects cat
+ 5, // unselects
+ 6, // selects otter
+ 7, // unselects
+ 8, // reselects
+ 9, // Owen unselects
+ 10, // Owen closes page
+
+ 12, // the "Thanks everyone" comment
+ 13, // Owen reopens page
+ 14, // Maria selects cat, accidentally
+ ]);
});
it("Owen leaves, Corax arrives", async () => {
diff --git a/tests/e2e-wdio7/utils/ty-e2e-test-browser.ts b/tests/e2e-wdio7/utils/ty-e2e-test-browser.ts
index 3628a44bcb..64bec67d9b 100644
--- a/tests/e2e-wdio7/utils/ty-e2e-test-browser.ts
+++ b/tests/e2e-wdio7/utils/ty-e2e-test-browser.ts
@@ -3654,16 +3654,16 @@ export class TyE2eTestBrowser {
deletePage: async () => {
await this.waitAndClick('.dw-a-tools');
- await this.waitUntilDoesNotMove('.e_DelPg');
- await this.waitAndClick('.e_DelPg');
+ await this.waitUntilDoesNotMove('.e_DelPgB');
+ await this.waitAndClick('.e_DelPgB');
await this.waitUntilModalGone();
await this.topic.waitUntilPageDeleted();
},
restorePage: async () => {
await this.waitAndClick('.dw-a-tools');
- await this.waitUntilDoesNotMove('.e_RstrPg');
- await this.waitAndClick('.e_RstrPg');
+ await this.waitUntilDoesNotMove('.e_UndelPgB');
+ await this.waitAndClick('.e_UndelPgB');
await this.waitUntilModalGone();
await this.topic.waitUntilPageRestored();
},
@@ -4921,7 +4921,7 @@ export class TyE2eTestBrowser {
},
editTitle: async (title: string) => {
- await this.waitAndSetValue('#e2eTitleInput', title);
+ await this.waitAndSetValue('.c_TtlE_TtlI input', title);
},
save: async () => {
@@ -6500,7 +6500,7 @@ export class TyE2eTestBrowser {
}
},
- assertMetaPostTextMatches: async (postNr: PostNr, text: St) => {
+ assertMetaPostTextMatches: async (postNr: PostNr, text: St | RegExp) => {
await this.assertTextMatches(`#post-${postNr} .s_MP_Text`, text)
},
diff --git a/tests/e2e-wdio7/yarn.lock b/tests/e2e-wdio7/yarn.lock
index 8fdb7a85a4..ea7b9e43ab 100644
--- a/tests/e2e-wdio7/yarn.lock
+++ b/tests/e2e-wdio7/yarn.lock
@@ -960,10 +960,10 @@ axios@^0.26.1:
dependencies:
follow-redirects "^1.14.8"
-axios@^1.6.7:
- version "1.6.8"
- resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66"
- integrity sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==
+axios@^1.7.4:
+ version "1.7.5"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.5.tgz#21eed340eb5daf47d29b6e002424b3e88c8c54b1"
+ integrity sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==
dependencies:
follow-redirects "^1.15.6"
form-data "^4.0.0"
@@ -1251,13 +1251,13 @@ chrome-launcher@^0.15.0:
is-wsl "^2.2.0"
lighthouse-logger "^1.0.0"
-chromedriver@^126.0.3:
- version "126.0.3"
- resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-126.0.3.tgz#5c1c8f586b0832f00286391218a56460b2d605c5"
- integrity sha512-4o+ZK8926/8lqIlnnvcljCHV88Z8IguEMB5PInOiS9/Lb6cyeZSj2Uvz+ky1Jgyw2Bn7qCLJFfbUslaWnvUUbg==
+chromedriver@^128.0.0:
+ version "128.0.0"
+ resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-128.0.0.tgz#7f75a984101199e0bcc2c92fe9f91917fcd1f918"
+ integrity sha512-Ggo21z/dFQxTOTgU0vm0V59Mi79yyR+9AUk/KiVAsRfbDRdVZQYQWfgxnIvD/x8KOKn0oB7haRzDO/KfrKyvOA==
dependencies:
"@testim/chrome-version" "^1.1.4"
- axios "^1.6.7"
+ axios "^1.7.4"
compare-versions "^6.1.0"
extract-zip "^2.0.1"
proxy-agent "^6.4.0"
diff --git a/tests/e2e/utils/pages-for.ts b/tests/e2e/utils/pages-for.ts
index 77e03332d7..143e2247e8 100644
--- a/tests/e2e/utils/pages-for.ts
+++ b/tests/e2e/utils/pages-for.ts
@@ -3269,16 +3269,16 @@ export class TyE2eTestBrowser {
deletePage: () => {
this.waitAndClick('.dw-a-tools');
- this.waitUntilDoesNotMove('.e_DelPg');
- this.waitAndClick('.e_DelPg');
+ this.waitUntilDoesNotMove('.e_DelPgB');
+ this.waitAndClick('.e_DelPgB');
this.waitUntilModalGone();
this.topic.waitUntilPageDeleted();
},
restorePage: () => {
this.waitAndClick('.dw-a-tools');
- this.waitUntilDoesNotMove('.e_RstrPg');
- this.waitAndClick('.e_RstrPg');
+ this.waitUntilDoesNotMove('.e_UndelPgB');
+ this.waitAndClick('.e_UndelPgB');
this.waitUntilModalGone();
this.topic.waitUntilPageRestored();
},
@@ -4376,7 +4376,7 @@ export class TyE2eTestBrowser {
},
editTitle: (title: string) => {
- this.waitAndSetValue('#e2eTitleInput', title);
+ this.waitAndSetValue('.c_TtlE_TtlI input', title);
},
save: () => {
diff --git a/version.txt b/version.txt
index f3959c16ee..798315efb5 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-v0.2024.005
+v0.2024.006
diff --git a/wip/aliases/auto-test-thoughts.txt b/wip/aliases/auto-test-thoughts.txt
new file mode 100644
index 0000000000..ee3c20d892
--- /dev/null
+++ b/wip/aliases/auto-test-thoughts.txt
@@ -0,0 +1,64 @@
+ Category:
+ Anons always:
+
+ Self selected:
+ Anons allowed / recommended:
+
+ Pseudonym selected:
+ Anons allowed / recommended:
+
+ Anonym selected:
+ Anons allowed / recommended:
+
+ Nothing selected:
+ Anons allowed:
+ Anons recommended:
+
+
+ Page:
+ Self selected:
+ Has anon already:
+ Anons allowed:
+ Anons recommended:
+ Anons always:
+
+ Has no anon:
+ Anons allowed:
+ Anons recommended:
+ Anons always:
+
+
+ Pseudonym selected:
+ Has anon already:
+ Anons allowed:
+ Anons recommended:
+ Anons always:
+
+ Has no anon:
+ Anons allowed:
+ Anons recommended:
+ Anons always:
+
+
+ Anonym selected:
+ Has anon already:
+ Anons allowed:
+ Anons recommended:
+ Anons always:
+
+ Has no anon:
+ Anons allowed:
+ Anons recommended:
+ Anons always:
+
+ Nothing selected:
+ Has anon already:
+ Anons allowed:
+ Anons recommended:
+ Anons always:
+
+ Has no anon:
+ Anons allowed:
+ Anons recommended:
+ Anons always:
+
diff --git a/wip/aliases/wip.txt b/wip/aliases/wip.txt
index 7ce8904598..6ff1011488 100644
--- a/wip/aliases/wip.txt
+++ b/wip/aliases/wip.txt
@@ -4,12 +4,12 @@ Alias = anonym or pseudonym
Intro:
-- Anonyms are per page. This is safer — can't track a single anonym accross different
- conversations. And more social? In that, if you want, you can show who you
- actually are, in one discussion, while staying anonymous in all others.
+- Anonyms are per page. [one_anon_per_page] This is safer — can't track a single anonym
+ accross different conversations. And more social? In that, if you want, you can show
+ who you actually are, in one discussion, while remaining anonymous in all others.
And good for ideation: Anonymous, temporarily. See the Ideation section.
- Pseduonyms (a.k.a pen names) can be reused accross pages and categories.
- They are more social, but less "safe".
+ They are more social, but less "safe". (Not implemented.)
- Both anonyms and pseudonyms inherit (some of) the permissions of the underlying
true user. (Later, this will be more configurable? But not initially?)
See [pseudonym_types] below.
@@ -30,35 +30,53 @@ then, this is a big box of butterflies!
Left to do
-----------------------
-Quick:
+Now:
+ - Save persona mode in ... localStorage? Cookie? [remember_persona_mode]
+ - [hide_peoples_dates] [deanon_risk]
+ - Notifications: List notifications to all one's aliases (anonyms & pseudonyms),
+ not just to oneself. [list_alias_things] [posts3_true_id]
+ - Actions: List comments & pages by one's aliases, on one's recent comment page
+ (but of course others, not even mods or admins, can see this). [list_by_alias]
+ - Bind anonForPatId to ... Unknown user id? for now, in json dumps. [export_privid]
+
+
+Later, quick:
- RENAME // to ...ByTrueId?
val anonsByRealId = anons.groupBy(_.anonForPatId)
-
- - Code review:
- - tests/e2e-wdio7/specs/alias-anons-basic.2br.f.e2e.ts
- - tests/e2e-wdio7/specs/alias-anons-true-mixed.2br.f.e2e.ts
+ - Rename def getAliasOrTruePat() to getPersona()? And "nnnMaybeAnon" to "nnnPersona"?
Minor & medium:
- - Notifications: List notifications to all one's aliases (anonyms & pseudonyms),
- not just to oneself. [list_alias_things]
- - Actions: List comments & pages by one's aliases, on one's recent comment page
- (but of course others, not even mods or admins, can see this).
- - Authz.scala: [_pass_alias] [deanon_risk]
- - Reuse getAliasOrTruePat() everywhere [get_anon]
- - Accept/unaccept answer
- - Close/reopen page
- - Delete/undelete own page
- - Rename page
- - mayEditPage(.., asAlias, ...) in PageTitleSettingsController [may_use_alias]
- - Flag (report) things anonymously?
- - Search for all DO_AS_ALIAS
- - Search for all ANON_UNIMPL
-(Also see: [anon_pages])
+ - Remember post-as-oneself in drafts3, use post_as_id_c, and,
+ - Remember anon status, also when reusing anons, in drafts3.
+ - Verify anonStatus is correct, when reusing an anon or resuming a draft [chk_alias_status]
+
+ - Button title: "Post as yourself", "Post anonymously]", in >= anon-allowed cats.
+ - Require 2 clicks to choose anon, but not at the same coordinates? [deanon_risk] [mouse_slip]
-Later:
+ - Let aliases edit wikis [alias_ed_wiki] (normally, editing
+ others' posts anonymously isn't allowed)
+ - wikify-dialog.more.ts choose persona (if posted as anon, should wikify as same anon)
+ - Flag (report) things anonymously? [anon_flags]
+ - Search for all ANON_UNIMPL
+ - Better error messages: [alias_ux] [dif_anon_status]
+ - All audit log doerTrueId should be like: doingAs.trueId2 not reqr.trueId2
+ - Let mods & admins edit their own anon posts as themselves? See [true_0_ed_alias]
+ and [mods_ed_own_anon] in _checkDeanonRiskOfEdit().
+
+ - An optional intro guide that explains anon comments? [anon_comts_guide]
+ Like the admin intro tour, but about anon comments & votes, and for everyone.
+ See `staffTours: StaffTours` in ../../client/app-staff/admin/staff-tours.staff.ts.
+
+ - Also maybe show a dialog if a mod or admin edits group settings or helps someone
+ configure their settings: Clarify that such changes aren't anonymous.
+ Basically any changes on the user and group profile pages.
+
+Even later:
+ - A [see_alias] permission, so can choose to see who an anonym is,
+ or can vote to see who an anon is.
- Ask the server which alias to use, instead of deriving client side [fetch_alias]
- e.g. if the page is big? Also prevents any misbehaving clients.
+ e.g. if the page is big?
- Incl true id in json dumps, depending on site sensitivity settings [export_privid]
Maybe add sth like talkyard.server.parser.JsonConf that says if any doerId.privId
should be included or not? SensitiveConf or PrivacyConf maybe?
@@ -70,22 +88,39 @@ Later:
- Think about anonymous votes and how many have read this-or-that comment,
when the same person visits & comments sometimes as hanself, sometimes
using an alias. See: PagePopularityCalculator [alias_vote_stats]
+ Answer: If sensitive discussions, each anonym has the same weight (doesn't matter who
+ the true user is). And if ideation, and temporary anon comments: Then, votes might even
+ be hidden (to prevent anchoring bias and social biases), and later, when showing
+ people's votes, people aren't temp-anonymous any more anyway (so, can calculate
+ page popularity as usual).
- [pseudonyms_later] [pseudonyms_trust]
- - [anon_priv_msgs][anon_chats]
+ - Later: Anonymous chat messages? [anon_chats]
- [sql_true_id_eq]
Look at later, realted to anonyms and pseudonyms (looking up on both true id & anon id).
+ - Anonymous/pseudonymous direct messages [anon_priv_msgs]. (Disabled by default?)
+ - Panic button
+ - Ask which persona to use, if changing category when composing new topic. [ask_if_needed]
+ (Not that important, now there's an info message.)
+ - Pass doer (usually the author) and trueDoer to NotificationGenerator,
+ so won't need to lookup inside. [notf_pass_doer]
Tests missing:
/^alias/ in test-map.txt.
Large:
- - Alias selector (see below)
- - Dim buttons
+ - Alias selector (see below) ... Oh, now implemented alraedy.
+
+ - Dim buttons [_dim_buttons] (that you cannot click if in Anon mode — but this requires
+ the client to somehow reproduce all can-do-what permission calculations the server does,
+ and for both (!) oneself (does a button appear at all), and one's current persona
+ (should the button be dimmed, that is, one can click it using one's real account,
+ but not as one's current persona).
- Anonymous moderator actions [anon_mods]: Let mods do things anonymously,
but using different per-page-&-user anons, so no one can see if
replies by an anon, are in fact by one of the mods (if that anon later did
- sth only mods can do). — Look at all `getAliasOrTruePat()` and [get_anon].
+ sth only mods can do). — Look at all `getAliasOrTruePat()`.
+ Or maybe post-as-group is better, all that's needed? (see just below)
- Group aliases / post as group? [group_pseudonyms]
So many people can post, using the same name.
@@ -98,12 +133,22 @@ Much later:
- More anonymous modes, e.g. one that doesn't remember who an anonym is — not in email logs,
audit logs, etc.
+Skip?:
+ - Anonymous embedded comments. Not that important — can comment as a guest instead?
+ Would need to ask which persona to use, in the comments iframe, before opening editor,
+ so can show dialog next to the reply/edit btns.
+ - Optionally disallow moving a page with anonymous comments, to a category
+ where anon comments aern't allowed.
+ See: mayEditPage(.., asAlias, ...) in PageTitleSettingsController [move_anon_page]
+ - One person with [many_anons_per_page]. (Except for anonymous moderation, but maybe
+ a shared moderator pseudonym is better.)
+
For sensitive discussions
=======================
-Alias selector [alias_mode] [choose_alias]
+Alias selector [persona_mode] [alias_mode]
-----------------------
A preferred-alias selector in one's upper right username menu, which determines
which of one's aliases gets used, in places where one can post as:
@@ -114,23 +159,19 @@ which of one's aliases gets used, in places where one can post as:
If you comment or vote on a page where you have used another alias that the one
currently selected (in the alias selector), then, Talkyard asks:
"You previously commented here as X, but you've activated pseudonym Y.
- Continue commenting as X? [yes, as X / no, as Y / cancel]"
+ Continue commenting as: X? Y? [Cancel]"
-And, since Y is a somewhat unlikely answer, then dobuble check:
+And, since Y is a somewhat unlikely answer, dobuble check: (not impl) [mouse_slip]
"Ok, you'll appear as Y [yes, fine / no, cancel]"
-Or, if the current mode is Anonymous, and you visit a discussion where you've
-replied as yourself previously, and you hit Reply, then, a dialog:
-
- "Continue using your real name in this discussion? [y / n]
- You're in Anonymous mode but on this page you've used your real name previously."
-
(Might seem as if this double check question is an extra step, but it'll almost
never happen that anyone replies as X and later on what to continue as Y in the
same discussion. — Maybe shouldn't even be allowed; could be a config setting.)
+Or, if the current mode is Anonymous, and you visit a discussion where you've
+replied as yourself previously, then the choose-persona dialog appears too.
-Dim buttons
+Dim buttons [_dim_buttons]
-----------------------
When a not-oneself alias has been selected, then, all buttons that don't work
with alias mode, could be dimmed a bit? For example, if one is a moderator,
@@ -171,12 +212,12 @@ Think about
Can even make sense with different types of pseudonyms?
- One "type" that has the same access permissions as one's true account?
- Another that doesn't inherit anything from one's true account?
- But can instead be granted permissions independently of one's true account.
+ But can optionally be granted permissions independently of one's true account.
This, though, is more like a completely separate account, but you access it
by first logging in to your true account, then switching accounts.
-Guessing who is who? [deanon_risk]
+Guessing who is who? [deanon_risk] [mod_deanon_risk]
-----------------------
- People's slightly unique writing style can be a "problem", can be mitigated by letting
an AI transform all anon comments to a similar writing style.
@@ -184,8 +225,10 @@ Guessing who is who? [deanon_risk]
comments from the same time span, otherwise just silence the whole night. Repeated a few
times, then, easy to make a good guess about who posted the anon comments. — Mitigated
by hiding last-active-at & posted-at timestamps, or making timestamps coarse-grained,
- e.g. showing only day or week (but not hour and minute).
+ e.g. showing only day or week (but not hour and minute). [hide_peoples_dates]
- Inherits permissions — see [pseudonym_types] below.
+ - Posting anonymously in a category few people have access too.
+ - Anonymously moving a page from one access restricted cat to another.
- 9999 more things...
When posting anonymously, often safer to [not_use_true_users]' permissions.
@@ -203,19 +246,11 @@ Lots of work
good guesses about who the pseudonyms are?
- If repeatedly doing very different things that few others can do,
using the same pseudonym, then what? Calculate total % likelihood that someone
- correctly guesses their true identidy, looking at all past actions, and if too high,
+ correctly guesses their true identity, looking at all past actions
+ (can use AI and spend a bunch of lifetimes, researching this?), and if too high,
consider switching to a new pseudonym? — Maybe simpler to just suggest that
the user switches to a new pseudonym after N time units or M interactions/comments.
-
-Would be nice
------------------------
-- Pass doer (usually the author) and trueDoer to NotificationGenerator,
- so won't need to lookup inside. [notf_pass_doer]
-
-
-Unnecessary
------------------------
- If an admin Alice impersonates Bob, then, even if Bob's list of aliases are hidden
(so Alice can't see them), it can still be partly possible for Alice to figure out
which aliases are Bob's aliases — by looking at when Bob did *not* get notified about
@@ -227,8 +262,11 @@ Unnecessary
to impersonate anyone (say, >= 2 admins need to agree) combined with
impersonation notifications [imp_notfs].
-- One person with [many_anons_per_page]. (Except for anonymous moderation, but maybe
- a shared moderator pseudonym is better.)
+ Or, hide notifications, if impersonating. Maybe could be different impersonation
+ capabilities, e.g. see true user's comments / see anon comments. Can be nice if
+ it's possible for an admin to help sbd out by impersonating their account and
+ figure out why sth doesn't work, *without* any chance that the admin accidentally
+ learns who that person are, anonymously.
For ideation
diff --git a/wip/bookmarks/bookmarks-wip.txt b/wip/bookmarks/bookmarks-wip.txt
new file mode 100644
index 0000000000..56117dcc9b
--- /dev/null
+++ b/wip/bookmarks/bookmarks-wip.txt
@@ -0,0 +1,2 @@
+
+[dont_list_bookmarkers]
diff --git a/wip/joint-decisions/joint-decisions.txt b/wip/joint-decisions/joint-decisions.txt
index 877bd74d8c..6c879aac8f 100644
--- a/wip/joint-decisions/joint-decisions.txt
+++ b/wip/joint-decisions/joint-decisions.txt
@@ -8,7 +8,7 @@ If cannot trust all admins, then, optionally (forum wide settings) require many
to agree (e.g. two), and notify all other admins, if wants to do any of:
- Impersonating anyone. [imp_notfs]
-- See who an anonymous user or pseudonym is
+- See who an anonymous user or pseudonym is [see_alias]
- Looking at email addresses or IP addresses in email logs, request logs, audit logs
(e.g. when this anonym got a reply, to what email address did the notification get sent?).
- Changing any related forum setting.
diff --git a/wip/upgr-scala-2.13/upgr-scala-2.13.txt b/wip/upgr-scala-2.13/upgr-scala-2.13.txt
new file mode 100644
index 0000000000..e9b578bd4d
--- /dev/null
+++ b/wip/upgr-scala-2.13/upgr-scala-2.13.txt
@@ -0,0 +1,11 @@
+
+https://confadmin.trifork.com/dl/2018/GOTO_Berlin/Migrating_to_Scala_2.13.pdf
+
+https://github.com/guardian/maintaining-scala-projects/issues/2
+
+https://docs.scala-lang.org/overviews/core/collections-migration-213.html
+
+
+https://www.playframework.com/documentation/3.0.x/Migration29
+
+