mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
feat: user profile comments view + Talk view improvements
This commit is contained in:
17 files changed
+665
-61
No files matched your search
+104
-3
@@ -1,6 +1,6 @@
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { maskSensitiveProps } from '../helpers/common.ts'
|
||||
import { mayOnPage, unpublishedFor } from './pages.ts'
|
||||
import { mayOnPage, unlockedFor, unpublishedFor } from './pages.ts'
|
||||
import {
|
||||
ANNOTATION_NOTE_MAX_LENGTH,
|
||||
ANNOTATIONS_MAX,
|
||||
@@ -243,7 +243,7 @@ async function routes(app: FastifyInstance) {
|
||||
schema: {
|
||||
summary: 'List the comments of a page',
|
||||
description:
|
||||
'Every comment on the page, oldest first and flat — the one level of nesting is assembled by the client from `parentId`, which keeps a reply beside the comment it answers however old that comment is.\n\nOnly for a site using the built-in provider; a site whose discussions live at a third party answers 404 here. Needs `read:comments` on the page, which the guests group may hold.\n\n`mentions` resolves the handles written in these comments, so that a mention is drawn as a link to the right person without a lookup per `@`.',
|
||||
'Every comment on the page, oldest first and flat — the one level of nesting is assembled by the client from `parentId`, which keeps a reply beside the comment it answers however old that comment is.\n\nOnly for a site using the built-in provider; a site whose discussions live at a third party answers 404 here. Needs `read:comments` on the page, which the guests group may hold. A password-protected page answers 403 until the session has unlocked it (`POST …/unlock`), as every comment route does: the discussion is behind the password along with the body.\n\n`mentions` resolves the handles written in these comments, so that a mention is drawn as a link to the right person without a lookup per `@`.',
|
||||
tags: ['Comments'],
|
||||
params: pageIdParam,
|
||||
response: {
|
||||
@@ -289,6 +289,73 @@ async function routes(app: FastifyInstance) {
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* LIST A USER'S COMMENTS
|
||||
*/
|
||||
/*
|
||||
No route-level `permissions`, for the reason above: what decides each comment is the reader's page
|
||||
rules on the page it is on, which the model settles row by row before it pages.
|
||||
*/
|
||||
app.get<{
|
||||
Params: { siteId: string; userId: string }
|
||||
Querystring: { offset?: number; limit?: number }
|
||||
}>(
|
||||
'/sites/:siteId/users/:userId/comments',
|
||||
{
|
||||
schema: {
|
||||
summary: 'List the comments a user has written',
|
||||
description:
|
||||
'The comments and replies one user wrote on this site, newest first — the Comments tab of their public profile. Each comes with a short excerpt and the page it is on.\n\nOnly what the caller could open on the page itself is listed: `read:pages` and `read:comments` on the page, the page live (or one the caller may see unpublished), its password satisfied where it has one (unlocked in this session, or a caller the password does not bind), and the page still taking comments. Both the batch and `total` are counted after that, so neither says anything about comments the caller may not read. Deleted comments are never listed.\n\nOnly for a site using the built-in provider; any other answers 404.',
|
||||
tags: ['Comments'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: { type: 'string', format: 'uuid' },
|
||||
userId: { type: 'string', format: 'uuid' }
|
||||
},
|
||||
required: ['siteId', 'userId']
|
||||
},
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
offset: { type: 'integer', minimum: 0, default: 0 },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 25 }
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'One batch of the comments this caller may read',
|
||||
type: 'object',
|
||||
properties: {
|
||||
results: {
|
||||
type: 'array',
|
||||
items: { $ref: 'AuthoredComment#' }
|
||||
},
|
||||
total: {
|
||||
type: 'integer',
|
||||
description: 'How many of this user’s comments the caller may read, in all.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
if (!WIKI.models.comments.usesBuiltIn(req.params.siteId)) {
|
||||
return reply.notFound('This site does not use the built-in comments provider.')
|
||||
}
|
||||
return WIKI.models.comments.listByAuthor({
|
||||
siteId: req.params.siteId,
|
||||
authorId: req.params.userId,
|
||||
actor: WIKI.models.groups.actorForRequest(req),
|
||||
unpublished: unpublishedFor(req),
|
||||
unlocked: (pageId) => unlockedFor(req, pageId),
|
||||
offset: req.query.offset,
|
||||
limit: req.query.limit
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST A COMMENT
|
||||
*/
|
||||
@@ -995,16 +1062,42 @@ async function requireBuiltInPage(
|
||||
}
|
||||
// -> Carrying the site, since everything below asks a page rule about this page and a rule may be
|
||||
// limited to particular sites
|
||||
const { isLive, ...ref } = { ...row, siteId: req.params.siteId }
|
||||
const { isLive, isProtected, ...ref } = { ...row, siteId: req.params.siteId }
|
||||
// -> A page that is not live is not there for its discussion either, on the page view's own terms
|
||||
const unpublished = unpublishedFor(req)
|
||||
if (!isLive && !(unpublished && unpublished(ref))) {
|
||||
reply.notFound('This page does not exist.')
|
||||
return null
|
||||
}
|
||||
if (!passwordSatisfied(req, reply, { id: ref.id, isProtected })) {
|
||||
return null
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the caller is past the page's password, answering 403 when they are not.
|
||||
*
|
||||
* A page behind a password withholds its discussion along with its body: comments quote the page,
|
||||
* annotations word for word, and talk about what it says. So until the session has unlocked the page
|
||||
* (`POST …/unlock`), or the caller is somebody the password does not bind (`mayBypassPassword`), every
|
||||
* comment route refuses it -- reading, posting, and acting on what is already there, moderators
|
||||
* included. The same 403 the history and links routes give a locked page.
|
||||
*
|
||||
* @returns Whether to go on; false once it has sent the reply itself
|
||||
*/
|
||||
function passwordSatisfied(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
page: { id: string; isProtected: boolean }
|
||||
): boolean {
|
||||
if (page.isProtected && !unlockedFor(req, page.id)) {
|
||||
reply.forbidden('This page is password protected.')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The comment a request is about, once it is established that the caller may change it.
|
||||
*
|
||||
@@ -1031,6 +1124,9 @@ async function requireWritableComment(
|
||||
reply.notFound('This comment does not exist.')
|
||||
return null
|
||||
}
|
||||
if (!passwordSatisfied(req, reply, { id: comment.pageId, isProtected: comment.isProtected })) {
|
||||
return null
|
||||
}
|
||||
const page = {
|
||||
siteId: req.params.siteId,
|
||||
path: comment.path,
|
||||
@@ -1075,6 +1171,11 @@ async function requireAnnotation(
|
||||
reply.notFound('This annotation does not exist.')
|
||||
return null
|
||||
}
|
||||
if (
|
||||
!passwordSatisfied(req, reply, { id: annotation.pageId, isProtected: annotation.isProtected })
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const page = {
|
||||
siteId: req.params.siteId,
|
||||
path: annotation.path,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { unlockedFor } from './pages.ts'
|
||||
import { NOTIFICATION_CATEGORY_KEYS } from '../notifications/index.ts'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
|
||||
@@ -101,7 +102,10 @@ async function routes(app: FastifyInstance) {
|
||||
if (!userId || !siteId) {
|
||||
return reply
|
||||
}
|
||||
return WIKI.models.notifications.list(userId, siteId, req.query)
|
||||
return WIKI.models.notifications.list(userId, siteId, {
|
||||
...req.query,
|
||||
unlocked: (pageId) => unlockedFor(req, pageId)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -804,9 +804,10 @@ async function routes(app: FastifyInstance) {
|
||||
/*
|
||||
The badge on the Talk tab, which has to be there before the tab is opened — so it comes with
|
||||
the page rather than with the comments. One indexed count, and not even that for a site
|
||||
whose discussions live at a third party or that has comments turned off.
|
||||
whose discussions live at a third party or that has comments turned off -- nor for a page
|
||||
still waiting for its password, whose discussion is behind it with the body.
|
||||
*/
|
||||
WIKI.models.comments.usesBuiltIn(req.params.siteId)
|
||||
WIKI.models.comments.usesBuiltIn(req.params.siteId) && !page.isLocked
|
||||
? WIKI.models.comments.countForPage(page.id)
|
||||
: 0,
|
||||
/*
|
||||
@@ -933,7 +934,12 @@ async function routes(app: FastifyInstance) {
|
||||
path: page.path
|
||||
})
|
||||
|
||||
return page
|
||||
// -> The Talk tab's badge, withheld while the page was locked (see the page route) and now the
|
||||
// reader's to see along with the discussion itself
|
||||
const commentsCount = WIKI.models.comments.usesBuiltIn(req.params.siteId)
|
||||
? await WIKI.models.comments.countForPage(page.id)
|
||||
: 0
|
||||
return { ...page, commentsCount }
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -215,6 +215,38 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* AUTHORED COMMENT - One comment in a listing of what one person wrote, with the page it is on
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'AuthoredComment',
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
parentId: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
nullable: true,
|
||||
description: 'The comment this one answers, or null for one that starts a thread.'
|
||||
},
|
||||
excerpt: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The start of the markdown source, cut at a fixed length with no regard for its syntax -- enough for a line or two of preview, never the whole comment. Empty for a comment whose annotations say everything.'
|
||||
},
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
annotationCount: {
|
||||
type: 'integer',
|
||||
description: 'How many passages of the article it annotates.'
|
||||
},
|
||||
pageId: { type: 'string', format: 'uuid' },
|
||||
pageTitle: { type: 'string' },
|
||||
pagePath: { type: 'string' },
|
||||
pageLocale: { type: 'string' }
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* MENTION TARGET - A handle that resolved to somebody
|
||||
*/
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description:
|
||||
'A snapshot of what the entry is about, taken when it happened, so that it can still be drawn after the page has moved or gone: `page` (`id`, `title`, `path`, `locale`), `actorName`, `variants` (every variant absorbed), `previousPath`, `submissionId`, `origin` (`import` or `bulk` when nobody did it by hand), and `excerpt` — a comment’s first lines, present only while the comment exists.'
|
||||
'A snapshot of what the entry is about, taken when it happened, so that it can still be drawn after the page has moved or gone: `page` (`id`, `title`, `path`, `locale`), `actorName`, `variants` (every variant absorbed), `previousPath`, `submissionId`, `origin` (`import` or `bulk` when nobody did it by hand), and `excerpt` — a comment’s first lines, present only while the comment exists. A comment on a password-protected page this session has not unlocked carries `excerptWithheld: true` instead of `excerpt`.'
|
||||
},
|
||||
isRead: { type: 'boolean' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
|
||||
@@ -2010,8 +2010,10 @@
|
||||
"common.comments.beFirst": "Be the first to comment.",
|
||||
"common.comments.charsLeft": "{count} left",
|
||||
"common.comments.closed": "Comments are turned off for this page.",
|
||||
"common.comments.collapseReplies": "Collapse",
|
||||
"common.comments.commentsTitle": "Comments",
|
||||
"common.comments.contentMissingError": "Comment is empty or too short!",
|
||||
"common.comments.copyLink": "Copy link to this comment",
|
||||
"common.comments.deleteCommentOnly": "Delete this comment only",
|
||||
"common.comments.deleteConfirmTitle": "Delete Comment",
|
||||
"common.comments.deleteFailed": "Could not delete the comment.",
|
||||
@@ -2027,12 +2029,14 @@
|
||||
"common.comments.deleteWithReplies": "Delete comment + replies",
|
||||
"common.comments.deleted": "Comment was deleted.",
|
||||
"common.comments.edited": "edited",
|
||||
"common.comments.expandReplies": "Expand ({count} reply) | Expand ({count} replies)",
|
||||
"common.comments.fieldContent": "Comment Content",
|
||||
"common.comments.fieldEmail": "Your Email Address",
|
||||
"common.comments.fieldEmailHint": "Never shown to anybody. It is used to check the comment for spam.",
|
||||
"common.comments.fieldName": "Your Name",
|
||||
"common.comments.guest": "Guest",
|
||||
"common.comments.hideAllAnnotations": "Hide All",
|
||||
"common.comments.linkCopied": "Link to the comment copied to the clipboard.",
|
||||
"common.comments.loadFailed": "Could not load the comments.",
|
||||
"common.comments.loading": "Loading comments…",
|
||||
"common.comments.markdownFormat": "Markdown Format",
|
||||
@@ -3253,6 +3257,7 @@
|
||||
"notifications.categories.watchedPageComment.description": "Somebody comments on a page you watch.",
|
||||
"notifications.categories.watchedPageComment.title": "Comments on watched pages",
|
||||
"notifications.count": "{count} updates",
|
||||
"notifications.excerptWithheld": "This comment is not quoted here because the page is password protected. Go to the page to view it.",
|
||||
"notifications.messages.commentReply.new": "{actor} replied to your comment on {page}",
|
||||
"notifications.messages.mention.edited": "{actor} mentioned you in a comment on {page}",
|
||||
"notifications.messages.mention.new": "{actor} mentioned you in a comment on {page}",
|
||||
@@ -3498,17 +3503,23 @@
|
||||
"unsubscribe.invalid": "This unsubscribe link is not valid. You can still change your notification settings from your profile.",
|
||||
"unsubscribe.manage": "Notification settings",
|
||||
"unsubscribe.title": "Unsubscribe",
|
||||
"userProfile.commentAnnotationsOnly": "Annotated {count} passage | Annotated {count} passages",
|
||||
"userProfile.commentOn": "Comment on {title}",
|
||||
"userProfile.comments": "Comments",
|
||||
"userProfile.commentsLoadingFailed": "Failed to load comments.",
|
||||
"userProfile.lastLogin": "Last Login",
|
||||
"userProfile.loadMore": "Load More",
|
||||
"userProfile.loadingFailed": "Failed to load user profile.",
|
||||
"userProfile.localTime": "Local Time",
|
||||
"userProfile.location": "Location",
|
||||
"userProfile.noComments": "This user hasn't posted any comment yet.",
|
||||
"userProfile.noPagesCreated": "This user hasn't created any page yet.",
|
||||
"userProfile.noPagesUpdated": "This user hasn't modified any page yet.",
|
||||
"userProfile.notFound": "There is no such user.",
|
||||
"userProfile.pagesCreated": "Pages Created",
|
||||
"userProfile.pagesLoadingFailed": "Failed to load pages.",
|
||||
"userProfile.pagesUpdated": "Last Modified Pages",
|
||||
"userProfile.replyOn": "Reply on {title}",
|
||||
"userProfile.title": "User Profile",
|
||||
"welcome.admin": "Administration Area",
|
||||
"welcome.createHome": "Create the homepage",
|
||||
|
||||
+144
-3
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { load } from 'js-yaml'
|
||||
import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
|
||||
import { and, asc, count, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
|
||||
import {
|
||||
commentAnnotations as annotationsTable,
|
||||
comments as commentsTable,
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
parseModuleProps
|
||||
} from '../helpers/common.ts'
|
||||
import type { ModuleProp } from '../helpers/common.ts'
|
||||
import type { RulePageRef } from '../helpers/pageRules.ts'
|
||||
import { liveCondition } from '../helpers/publishing.ts'
|
||||
import type { AccessActor } from './groups.ts'
|
||||
|
||||
/**
|
||||
* The key of the provider that IS this wiki, as opposed to the ones that are somebody else's service.
|
||||
@@ -264,6 +266,28 @@ export interface CommentEntry {
|
||||
annotations: AnnotationEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a comment a listing of somebody's comments carries, in characters. A row shows a line or
|
||||
* two of it, so the rest of up to `COMMENT_MAX_LENGTH` would be bytes nobody reads.
|
||||
*/
|
||||
export const AUTHORED_EXCERPT_LENGTH = 400
|
||||
|
||||
/** One comment in a listing of what one person wrote, with the page it is on. */
|
||||
export interface AuthoredCommentEntry {
|
||||
id: string
|
||||
parentId: string | null
|
||||
/** The start of the markdown source, cut at `AUTHORED_EXCERPT_LENGTH` with no regard for syntax. */
|
||||
excerpt: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
/** How many passages it annotates -- what stands in for its text when it has none of its own. */
|
||||
annotationCount: number
|
||||
pageId: string
|
||||
pageTitle: string
|
||||
pagePath: string
|
||||
pageLocale: string
|
||||
}
|
||||
|
||||
/** A handle that resolved to somebody, as the renderer needs it to draw the mention as a link. */
|
||||
export interface MentionTarget {
|
||||
handle: string
|
||||
@@ -754,13 +778,121 @@ class Comments {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The comments one person has written on a site, newest first, as one reader may see them -- what
|
||||
* the Comments tab of a public profile lists.
|
||||
*
|
||||
* A comment is listed where the reader could follow it to the page's Talk view and find it there:
|
||||
* `read:pages` to open the page, `read:comments` for its discussion, the page live or one this reader
|
||||
* may see while it is not (`unpublished`, as the page view decides it), the password satisfied where
|
||||
* the page has one (`unlocked`) -- the discussion is behind it as much as the body is -- and the page
|
||||
* still taking comments, since with that switched off it has no Talk view to land on. Placeholders of deleted comments
|
||||
* are never listed; there is nothing of the author left in one.
|
||||
*
|
||||
* Decided BEFORE paging, as `search.searchPages` does it and for the same reason: a page rule can be
|
||||
* a pattern or a set of tags, so only a row can answer it, and a batch filtered after its `LIMIT`
|
||||
* comes back short with a total still counting what the reader was refused -- which is itself
|
||||
* something about pages they may not see. The first query reads only what a rule looks at, the
|
||||
* second the full rows of the one batch returned.
|
||||
*/
|
||||
async listByAuthor({
|
||||
siteId,
|
||||
authorId,
|
||||
actor,
|
||||
unpublished,
|
||||
unlocked,
|
||||
offset = 0,
|
||||
limit = 25
|
||||
}: {
|
||||
siteId: string
|
||||
authorId: string
|
||||
actor: AccessActor
|
||||
unpublished: false | ((page: RulePageRef) => boolean)
|
||||
/** Whether this reader has satisfied the password of a protected page, by its id. */
|
||||
unlocked: (pageId: string) => boolean
|
||||
offset?: number
|
||||
limit?: number
|
||||
}): Promise<{ results: AuthoredCommentEntry[]; total: number }> {
|
||||
const conditions = [
|
||||
eq(commentsTable.authorId, authorId),
|
||||
eq(pagesTable.siteId, siteId),
|
||||
isNull(commentsTable.deletedAt),
|
||||
sql`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)`
|
||||
]
|
||||
// -> The reader who may see no unpublished page anywhere is held to live ones here, as the search does
|
||||
if (unpublished === false) {
|
||||
conditions.push(liveCondition(pagesTable))
|
||||
}
|
||||
const candidates = await WIKI.db
|
||||
.select({
|
||||
id: commentsTable.id,
|
||||
pageId: pagesTable.id,
|
||||
path: pagesTable.path,
|
||||
locale: pagesTable.locale,
|
||||
tags: pagesTable.tags,
|
||||
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean),
|
||||
isProtected: sql<boolean>`${pagesTable.password} IS NOT NULL`.mapWith(Boolean)
|
||||
})
|
||||
.from(commentsTable)
|
||||
.innerJoin(pagesTable, eq(pagesTable.id, commentsTable.pageId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(commentsTable.createdAt), desc(commentsTable.id))
|
||||
|
||||
// -> Per page rather than per comment: a person's comments cluster on the pages they discuss
|
||||
const pageVerdicts = new Map<string, boolean>()
|
||||
const readable = candidates.filter((row) => {
|
||||
let verdict = pageVerdicts.get(row.pageId)
|
||||
if (verdict === undefined) {
|
||||
const ref = { siteId, path: row.path, locale: row.locale, tags: row.tags ?? [] }
|
||||
verdict =
|
||||
WIKI.models.groups.checkAccess(actor, 'read:pages', ref) &&
|
||||
WIKI.models.groups.checkAccess(actor, 'read:comments', ref) &&
|
||||
(row.isLive || (unpublished !== false && unpublished(ref))) &&
|
||||
(!row.isProtected || unlocked(row.pageId))
|
||||
pageVerdicts.set(row.pageId, verdict)
|
||||
}
|
||||
return verdict
|
||||
})
|
||||
const ids = readable.slice(offset, offset + limit).map((row) => row.id)
|
||||
if (ids.length < 1) {
|
||||
return { results: [], total: readable.length }
|
||||
}
|
||||
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
id: commentsTable.id,
|
||||
parentId: commentsTable.parentId,
|
||||
excerpt: sql<string>`left(${commentsTable.content}, ${AUTHORED_EXCERPT_LENGTH})`,
|
||||
createdAt: commentsTable.createdAt,
|
||||
updatedAt: commentsTable.updatedAt,
|
||||
annotationCount:
|
||||
sql<number>`(SELECT count(*) FROM ${annotationsTable} WHERE ${annotationsTable.commentId} = ${commentsTable.id})`.mapWith(
|
||||
Number
|
||||
),
|
||||
pageId: pagesTable.id,
|
||||
pageTitle: pagesTable.title,
|
||||
pagePath: pagesTable.path,
|
||||
pageLocale: pagesTable.locale
|
||||
})
|
||||
.from(commentsTable)
|
||||
.innerJoin(pagesTable, eq(pagesTable.id, commentsTable.pageId))
|
||||
.where(inArray(commentsTable.id, ids))
|
||||
// -> Back into the order the first query settled, which `IN` does not keep
|
||||
const byId = new Map(rows.map((row) => [row.id, row]))
|
||||
return {
|
||||
results: ids.map((id) => byId.get(id)).filter((row) => row !== undefined),
|
||||
total: readable.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page a comment is about, as everything that guards one needs it.
|
||||
*
|
||||
* Its path, locale and tags because that is what a page rule is matched against, and
|
||||
* `allowComments` because a page can be closed to discussion from its own properties dialog
|
||||
* whatever the site has configured. Deliberately not `pages.getPage` — that assembles a page for
|
||||
* reading, and this is a few columns and a scoping check. `isLive` is the caller's to act on.
|
||||
* reading, and this is a few columns and a scoping check. `isLive` and `isProtected` are the
|
||||
* caller's to act on.
|
||||
*
|
||||
* @returns The reference, or null when no such page exists on this site
|
||||
*/
|
||||
@@ -775,7 +907,10 @@ class Comments {
|
||||
allowComments: sql<boolean>`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)`,
|
||||
// -> A discussion quotes its page, annotations word for word, so it is only there while the
|
||||
// page is — see `helpers/publishing.ts`
|
||||
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean)
|
||||
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean),
|
||||
// -> Whether the page is behind a password, which hides its discussion as well as its body
|
||||
// until the session has unlocked it (`unlockedFor` in `api/pages.ts`)
|
||||
isProtected: sql<boolean>`${pagesTable.password} IS NOT NULL`.mapWith(Boolean)
|
||||
})
|
||||
.from(pagesTable)
|
||||
.where(and(eq(pagesTable.id, pageId), eq(pagesTable.siteId, siteId)))
|
||||
@@ -823,6 +958,9 @@ class Comments {
|
||||
locale: pagesTable.locale,
|
||||
tags: pagesTable.tags,
|
||||
title: pagesTable.title,
|
||||
// -> Whether the page is behind a password, which hides its discussion as well as its body
|
||||
// until the session has unlocked it (`unlockedFor` in `api/pages.ts`)
|
||||
isProtected: sql<boolean>`${pagesTable.password} IS NOT NULL`.mapWith(Boolean),
|
||||
allowComments: sql<boolean>`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)`
|
||||
})
|
||||
.from(commentsTable)
|
||||
@@ -1015,6 +1153,9 @@ class Comments {
|
||||
path: pagesTable.path,
|
||||
locale: pagesTable.locale,
|
||||
tags: pagesTable.tags,
|
||||
// -> Whether the page is behind a password, which hides its discussion as well as its body
|
||||
// until the session has unlocked it (`unlockedFor` in `api/pages.ts`)
|
||||
isProtected: sql<boolean>`${pagesTable.password} IS NOT NULL`.mapWith(Boolean),
|
||||
title: pagesTable.title
|
||||
})
|
||||
.from(annotationsTable)
|
||||
|
||||
+11
-4
@@ -58,6 +58,8 @@ export interface MailNotificationEntry {
|
||||
pageTitle: string
|
||||
/** A comment's first lines, as typed. */
|
||||
excerpt?: string
|
||||
/** Set in place of `excerpt` for a comment on a password-protected page, which is not quoted. */
|
||||
excerptWithheld?: boolean
|
||||
/** Set when nobody did it by hand: `import` or `bulk`. */
|
||||
origin?: string
|
||||
/** Where the entry leads. */
|
||||
@@ -564,6 +566,13 @@ class Mail {
|
||||
...(entry.count > 1 ? [t('notifications.count', { count: entry.count })] : []),
|
||||
...(entry.origin ? [t(`notifications.origin.${entry.origin}`)] : [])
|
||||
].join(' · ')
|
||||
// -> What stands where the comment would be quoted: its text, or why it is not there
|
||||
const quoteOf = (entry: MailNotificationEntry) =>
|
||||
entry.excerptWithheld
|
||||
? t('notifications.excerptWithheld')
|
||||
: entry.excerpt
|
||||
? `“${entry.excerpt}”`
|
||||
: ''
|
||||
const footer = t('mail.notification.footer', { siteName })
|
||||
const links = [
|
||||
{ label: t('mail.notification.manage'), url: d.manageUrl },
|
||||
@@ -577,7 +586,7 @@ class Mail {
|
||||
return {
|
||||
subject: t('mail.notification.subject', { siteName, message }),
|
||||
title: message,
|
||||
body: [...(entry.excerpt ? [`“${entry.excerpt}”`] : []), ...(detail ? [detail] : [])],
|
||||
body: [quoteOf(entry), detail].filter(Boolean),
|
||||
action: {
|
||||
label: t(`mail.notification.actions.${entry.category}`),
|
||||
url: entry.url
|
||||
@@ -593,9 +602,7 @@ class Mail {
|
||||
title: t('mail.notificationDigest.title', { siteName }),
|
||||
body: [t('mail.notificationDigest.body', { count: total })],
|
||||
items: d.entries.map((entry) => {
|
||||
const detail = [entry.excerpt ? `“${entry.excerpt}”` : '', detailOf(entry)]
|
||||
.filter(Boolean)
|
||||
.join(' — ')
|
||||
const detail = [quoteOf(entry), detailOf(entry)].filter(Boolean).join(' — ')
|
||||
return { text: describe(entry), url: entry.url, ...(detail && { detail }) }
|
||||
}),
|
||||
action: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { and, count, desc, eq, gte, inArray, isNotNull, isNull, lt, or, sql } fr
|
||||
import {
|
||||
notificationEvents as eventsTable,
|
||||
notifications as notificationsTable,
|
||||
pages as pagesTable,
|
||||
userNotificationPrefs as prefsTable
|
||||
} from '../db/schema.ts'
|
||||
import { durationToSeconds } from '../helpers/common.ts'
|
||||
@@ -381,11 +382,27 @@ class Notifications {
|
||||
*
|
||||
* Keyset-paginated on `(updatedAt, id)` — the cursor is the last entry of the previous page — since
|
||||
* an entry that absorbs a new event moves to the top, and an offset would skip or repeat around it.
|
||||
*
|
||||
* **A comment on a password-protected page is not quoted** until the reader has satisfied that
|
||||
* password (`unlocked`, which is `unlockedFor` in `api/pages.ts`): the entry says `excerptWithheld`
|
||||
* in place of the excerpt, as the Talk view refuses the comment itself. Decided from the page as it
|
||||
* is NOW rather than when the entry was written, so a password added since withholds what is already
|
||||
* in the inbox, and one taken off quotes it again -- the excerpt stays in the snapshot either way.
|
||||
*/
|
||||
async list(
|
||||
userId: string,
|
||||
siteId: string,
|
||||
{ cursor, unread, limit }: { cursor?: string; unread?: boolean; limit?: number } = {}
|
||||
{
|
||||
cursor,
|
||||
unread,
|
||||
limit,
|
||||
unlocked
|
||||
}: {
|
||||
cursor?: string
|
||||
unread?: boolean
|
||||
limit?: number
|
||||
unlocked: (pageId: string) => boolean
|
||||
}
|
||||
): Promise<{ entries: InboxEntry[]; next: string | null }> {
|
||||
const size = Math.min(Math.max(limit ?? INBOX_PAGE_SIZE, 1), INBOX_PAGE_MAX)
|
||||
const conditions = [this.inboxScope(userId, siteId)]
|
||||
@@ -410,9 +427,12 @@ class Notifications {
|
||||
data: notificationsTable.data,
|
||||
readAt: notificationsTable.readAt,
|
||||
createdAt: notificationsTable.createdAt,
|
||||
updatedAt: notificationsTable.updatedAt
|
||||
updatedAt: notificationsTable.updatedAt,
|
||||
// -> Null where the page has gone, which leaves nothing behind a password
|
||||
isProtected: sql<boolean>`${pagesTable.password} IS NOT NULL`.mapWith(Boolean)
|
||||
})
|
||||
.from(notificationsTable)
|
||||
.leftJoin(pagesTable, eq(pagesTable.id, notificationsTable.pageId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(notificationsTable.updatedAt), desc(notificationsTable.id))
|
||||
.limit(size + 1)
|
||||
@@ -420,12 +440,18 @@ class Notifications {
|
||||
const last = page[page.length - 1]
|
||||
return {
|
||||
entries: page.map((row) => {
|
||||
const { readAt, data, ...rest } = row
|
||||
const { readAt, data, isProtected, ...rest } = row
|
||||
const { excerpt, ...snapshot } = (data ?? {}) as Record<string, unknown>
|
||||
// -> A comment's text only while the comment exists: deleting it takes it out of every inbox
|
||||
const quoted = Boolean(row.commentId) && excerpt !== undefined
|
||||
const withheld = quoted && isProtected && !!row.pageId && !unlocked(row.pageId)
|
||||
return {
|
||||
...rest,
|
||||
// -> A comment's text only while the comment exists: deleting it takes it out of every inbox
|
||||
data: row.commentId && excerpt !== undefined ? { ...snapshot, excerpt } : snapshot,
|
||||
data: withheld
|
||||
? { ...snapshot, excerptWithheld: true }
|
||||
: quoted
|
||||
? { ...snapshot, excerpt }
|
||||
: snapshot,
|
||||
isRead: readAt !== null
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -198,6 +198,11 @@ props:
|
||||
refs:
|
||||
callbackUrl:
|
||||
title: Authorization Callback URL
|
||||
hint: Register this as the redirect URI at the provider. It is the same for every provider.
|
||||
hint: Register this as the redirect / callback URI at the provider. If you have multiple sites using this strategy, you must register all the site hostnames as well.
|
||||
icon: back
|
||||
value: '{host}/_api/auth/{id}/callback'
|
||||
logoutUrl:
|
||||
title: Post Logout URL
|
||||
hint: Register this as the post logout redirect / callback URI at the provider. If you have multiple sites using this strategy, you must register all the site hostnames as well. Note that if you override the logout URL in the site login settings or at the group level, you must also allow these URLs or use a wildcard match.
|
||||
icon: back
|
||||
value: '{host}/'
|
||||
@@ -2,6 +2,7 @@ import NodeCache from 'node-cache'
|
||||
import { and, asc, eq, inArray, lte, min, sql } from 'drizzle-orm'
|
||||
import {
|
||||
notifications as notificationsTable,
|
||||
pages as pagesTable,
|
||||
sites as sitesTable,
|
||||
userNotificationPrefs as prefsTable,
|
||||
users as usersTable
|
||||
@@ -60,6 +61,8 @@ interface DueRow {
|
||||
commentId: string | null
|
||||
data: Record<string, any>
|
||||
inApp: boolean
|
||||
/** Whether the page is behind a password now. False where the page has gone. */
|
||||
isProtected: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,8 +111,14 @@ function entryOf(baseUrl: string, row: DueRow): MailNotificationEntry {
|
||||
count: row.count,
|
||||
actorName: row.data.actorName ?? null,
|
||||
pageTitle: row.data.page?.title ?? '',
|
||||
// -> Only while the comment exists, the same rule the inbox applies
|
||||
...(row.commentId && row.data.excerpt && { excerpt: row.data.excerpt }),
|
||||
/*
|
||||
Only while the comment exists, the same rule the inbox applies -- and never for a page behind a
|
||||
password, which says so instead. Unlike the inbox there is no session here to have unlocked the
|
||||
page, and a mail goes wherever it is forwarded, so the password is never taken as satisfied.
|
||||
*/
|
||||
...(row.commentId &&
|
||||
row.data.excerpt &&
|
||||
(row.isProtected ? { excerptWithheld: true } : { excerpt: row.data.excerpt })),
|
||||
...(row.data.origin && { origin: row.data.origin }),
|
||||
url: urlOf(baseUrl, row)
|
||||
}
|
||||
@@ -161,7 +170,12 @@ async function claim(pair: { userId: string; siteId: string | null }): Promise<D
|
||||
AND "emailAfter" <= now()
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id, category, variant, count, "pageId", "commentId", data, "inApp", "updatedAt"
|
||||
RETURNING id, category, variant, count, "pageId", "commentId", data, "inApp", "updatedAt",
|
||||
-- Read at send time, so a password added since the comment was written still withholds it
|
||||
EXISTS (
|
||||
SELECT 1 FROM ${pagesTable}
|
||||
WHERE ${pagesTable.id} = ${notificationsTable.pageId} AND ${pagesTable.password} IS NOT NULL
|
||||
) AS "isProtected"
|
||||
`)
|
||||
return (result.rows as any[])
|
||||
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime())
|
||||
@@ -173,7 +187,8 @@ async function claim(pair: { userId: string; siteId: string | null }): Promise<D
|
||||
pageId: row.pageId,
|
||||
commentId: row.commentId,
|
||||
data: row.data ?? {},
|
||||
inApp: row.inApp
|
||||
inApp: row.inApp,
|
||||
isProtected: row.isProtected === true
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
|
||||
removing an icon; `check-icons.mjs` fails the build if this drifts.
|
||||
|
||||
313 icons.
|
||||
314 icons.
|
||||
*/
|
||||
export const BUNDLED_ICONS = {
|
||||
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
|
||||
"la:angle-left": {"body":"<path fill=\"currentColor\" d=\"m19.031 4.281l-11 11l-.687.719l.687.719l11 11l1.438-1.438L10.187 16L20.47 5.719z\"/>","width":32,"height":32},
|
||||
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
|
||||
"la:angle-up": {"body":"<path fill=\"currentColor\" d=\"m16 6.594l-.719.687l-12.5 12.5L4.22 21.22L16 9.437L27.781 21.22l1.438-1.438l-12.5-12.5z\"/>","width":32,"height":32},
|
||||
"la:arrow-circle-left": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m-.719 4.594L8.875 16l6.406 6.406L16.72 21l-4-4H23v-2H12.719l4-4z\"/>","width":32,"height":32},
|
||||
"la:arrow-circle-right": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m.719 4.594L15.28 11l4 4H9v2h10.281l-4 4l1.438 1.406L23.125 16z\"/>","width":32,"height":32},
|
||||
"la:arrow-left": {"body":"<path fill=\"currentColor\" d=\"m13.281 6.781l-8.5 8.5l-.687.719l.687.719l8.5 8.5l1.438-1.438L7.938 17H28v-2H7.937l6.782-6.781z\"/>","width":32,"height":32},
|
||||
|
||||
@@ -225,6 +225,26 @@
|
||||
in its place carries its own buttons then.
|
||||
-->
|
||||
<div class="page-comment-footer" v-if="hasActions">
|
||||
<!--
|
||||
Collapse / Expand, alone at the left end, with its icon over the line the replies hang off --
|
||||
it is that line, and what is on it, that the button folds away. See `.page-comment-toggle`.
|
||||
-->
|
||||
<w-btn
|
||||
v-if="replyCount > 0"
|
||||
class="page-comment-toggle"
|
||||
size="sm"
|
||||
padding="none xs"
|
||||
flat
|
||||
no-caps
|
||||
color="grey"
|
||||
:icon="repliesCollapsed ? `la:angle-down` : `la:angle-up`"
|
||||
:label="
|
||||
repliesCollapsed
|
||||
? t(`common.comments.expandReplies`, replyCount)
|
||||
: t(`common.comments.collapseReplies`)
|
||||
"
|
||||
:aria-expanded="repliesCollapsed ? `false` : `true`"
|
||||
@click="$emit(`toggle-replies`, comment)" />
|
||||
<w-btn
|
||||
v-if="comment.isDeleted"
|
||||
size="sm"
|
||||
@@ -237,11 +257,23 @@
|
||||
:disable="busy"
|
||||
@click="$emit(`delete-thread`, comment)" />
|
||||
<!--
|
||||
Reply at the far right, set apart from the two that change the comment -- answering is what
|
||||
the bar is mostly for, and Edit and Delete are a reader's own business or a moderator's. Those
|
||||
two are icons alone, with the word in a tooltip and for a screen reader.
|
||||
Reply at the far right, set apart from the ones before it -- answering is what the bar is
|
||||
mostly for, and Edit and Delete are a reader's own business or a moderator's. Those, and the
|
||||
link that leads them, are icons alone, with the word in a tooltip and for a screen reader.
|
||||
-->
|
||||
<template v-else>
|
||||
<!-- -> Offered to every reader: whoever can see the comment can point somebody else at it -->
|
||||
<w-btn
|
||||
size="sm"
|
||||
flat
|
||||
round
|
||||
dense
|
||||
color="grey"
|
||||
icon="la:link"
|
||||
:aria-label="t(`common.comments.copyLink`)"
|
||||
@click="copyLink">
|
||||
<w-tooltip>{{ t('common.comments.copyLink') }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn
|
||||
v-if="canEdit"
|
||||
size="sm"
|
||||
@@ -266,10 +298,7 @@
|
||||
@click="$emit(`delete`, comment)">
|
||||
<w-tooltip>{{ t('common.actions.delete') }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-separator
|
||||
v-if="canReply && (canEdit || canDelete)"
|
||||
vertical
|
||||
class="page-comment-footer-sep" />
|
||||
<w-separator v-if="canReply" vertical class="page-comment-footer-sep" />
|
||||
<w-btn
|
||||
v-if="canReply"
|
||||
size="sm"
|
||||
@@ -289,8 +318,11 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { notify } from '@/composables/notify'
|
||||
|
||||
import { relativeDate } from '@/helpers/datetime'
|
||||
import { avatarColorFor } from '@/helpers/avatarColors'
|
||||
import { copyToClipboard } from '@/helpers/clipboard'
|
||||
import { renderComment } from '@/renderers/comment'
|
||||
|
||||
import PageCommentEditor from '@/components/PageCommentEditor.vue'
|
||||
@@ -361,6 +393,16 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** How many replies hang off this comment. Only ever non-zero on the comment that opens a thread. */
|
||||
replyCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
/** Whether those replies are folded away. Owned by the list, which is what draws them. */
|
||||
repliesCollapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* Whether this comment is the one being edited.
|
||||
*
|
||||
@@ -380,6 +422,7 @@ const emit = defineEmits([
|
||||
'save',
|
||||
'delete',
|
||||
'delete-thread',
|
||||
'toggle-replies',
|
||||
'locate-annotation',
|
||||
'resolve-annotation',
|
||||
'edit-annotation',
|
||||
@@ -418,9 +461,11 @@ const hasAnnotations = computed(() => (props.comment.annotations?.length ?? 0) >
|
||||
/** Whether the footer has anything in it. See the template. */
|
||||
const hasActions = computed(() => {
|
||||
if (props.comment.isDeleted) {
|
||||
return props.canDeleteThread
|
||||
// -> A placeholder always has replies, but the count is what the toggle itself goes by
|
||||
return props.canDeleteThread || props.replyCount > 0
|
||||
}
|
||||
return !props.editing && (props.canReply || props.canEdit || props.canDelete)
|
||||
// -> Copy Link is always there, so only an edit in progress empties it
|
||||
return !props.editing
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -453,6 +498,22 @@ const avatarColor = computed(() =>
|
||||
|
||||
// METHODS
|
||||
|
||||
/**
|
||||
* Put a link to this comment on the clipboard: the page as it is addressed now, with `#comment-<id>`,
|
||||
* which opens the page on its Talk view and scrolls to the comment (`viewFromHash` in
|
||||
* `pages/Index.vue`). The query is left off -- it says something about this reader's visit, not about
|
||||
* the comment.
|
||||
*/
|
||||
async function copyLink() {
|
||||
const { origin, pathname } = window.location
|
||||
try {
|
||||
await copyToClipboard(`${origin}${pathname}#comment-${props.comment.id}`)
|
||||
notify({ type: 'positive', message: t('common.comments.linkCopied') })
|
||||
} catch (err) {
|
||||
notify({ type: 'negative', message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an annotation's passage was looked for in the article and not found. */
|
||||
function isLost(annotation) {
|
||||
return props.annotationsFound[annotation.id] === false
|
||||
@@ -499,6 +560,9 @@ watch(
|
||||
--reply-indent: 48px;
|
||||
--thread-line: #{$grey-4};
|
||||
|
||||
/* -> Where a scroll to the comment stops (`#comment-<id>`, View Comment on an annotation): a little
|
||||
way short of the card, so it lands with room above it rather than flush against the top */
|
||||
scroll-margin-top: 24px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
@@ -577,6 +641,17 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The comment a `#comment-<id>` link was followed to, so the reader can tell which card in a column of
|
||||
look-alike ones they were sent to. Keyed off the class `helpers/anchors.js` leaves on it, as a
|
||||
landed footnote is -- keep the name in step with `LANDED_CLASS` there. A ring rather than a wider
|
||||
border, so the card's contents and the thread line drawn from its edge do not move.
|
||||
*/
|
||||
.page-comment.is-anchor-landed {
|
||||
border-color: var(--q-primary);
|
||||
box-shadow: 0 0 0 1px var(--q-primary);
|
||||
}
|
||||
|
||||
.page-comment-main {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -606,6 +681,18 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Its icon centred on the thread line, which runs down the middle of the 36px avatar: `--comment-pad-x`
|
||||
plus 18px in from the card's inner edge, where the footer's own content starts 6px short of the
|
||||
padding. Less the button's 4px padding and half its icon, which is 1.715em wide in the button's own
|
||||
font size (`WBtn.vue`) -- so `em` here is the button's, and the sum holds at any size. The auto end
|
||||
margin sends everything after it to the far end, where the bar keeps its other actions.
|
||||
*/
|
||||
.page-comment-toggle {
|
||||
margin-inline-start: calc(24px - 4px - 0.8575em);
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.page-comment-footer-sep {
|
||||
align-self: stretch;
|
||||
margin: 4px 6px;
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
:editing-annotation-id="state.editingAnnotationId"
|
||||
:annotation-busy="state.busy"
|
||||
:can-delete-thread="canModerate"
|
||||
:reply-count="thread.replies.length"
|
||||
:replies-collapsed="Boolean(state.collapsed[thread.id])"
|
||||
@toggle-replies="toggleReplies"
|
||||
@reply="startReply"
|
||||
@edit="startEdit"
|
||||
@cancel-edit="state.editingId = null"
|
||||
@@ -41,7 +44,7 @@
|
||||
@save-annotation="saveAnnotation"
|
||||
@delete-annotation="confirmDeleteAnnotation" />
|
||||
<page-comment
|
||||
v-for="reply of thread.replies"
|
||||
v-for="reply of state.collapsed[thread.id] ? [] : thread.replies"
|
||||
:key="reply.id"
|
||||
:comment="reply"
|
||||
:mentions="state.mentions"
|
||||
@@ -121,7 +124,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useDark } from '@/composables/dark'
|
||||
@@ -214,7 +217,13 @@ const state = reactive({
|
||||
/** The annotation whose note is being edited, held here for the same reason as `editingId`. */
|
||||
editingAnnotationId: null,
|
||||
/** Whether each annotation's passage is still in the article, by id. See the note above. */
|
||||
annotationsFound: {}
|
||||
annotationsFound: {},
|
||||
/**
|
||||
* The threads whose replies are folded away, by the id of the comment that opens them. Kept across
|
||||
* a reload of the list -- posting somewhere else on the page should not unfold what was folded --
|
||||
* but not across pages.
|
||||
*/
|
||||
collapsed: {}
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
@@ -289,7 +298,10 @@ watch(
|
||||
// leaving the previous discussion under the new article
|
||||
watch(
|
||||
() => pageStore.id,
|
||||
() => load()
|
||||
() => {
|
||||
state.collapsed = {}
|
||||
load()
|
||||
}
|
||||
)
|
||||
|
||||
// METHODS
|
||||
@@ -355,6 +367,23 @@ async function load() {
|
||||
state.loading = false
|
||||
}
|
||||
|
||||
function toggleReplies(comment) {
|
||||
state.collapsed[comment.id] = !state.collapsed[comment.id]
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfold the thread a `#comment-<id>` fragment points into, if it is folded, so that the reply it
|
||||
* names exists to be scrolled to -- `pages/Index.vue` looks for it on the same hash change, and keeps
|
||||
* looking for a few seconds, so unfolding it here is enough.
|
||||
*/
|
||||
function onHashChange() {
|
||||
const id = window.location.hash.slice(1).replace(/^comment-/, '')
|
||||
const parentId = state.comments.find((c) => c.id === id)?.parentId
|
||||
if (parentId && state.collapsed[parentId]) {
|
||||
state.collapsed[parentId] = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(comment) {
|
||||
// -> One box at a time, and never two: a reply box open under a comment that is itself being
|
||||
// edited is two drafts of the same thing on screen
|
||||
@@ -367,6 +396,8 @@ function startReply(comment) {
|
||||
// -> A reply always attaches to the thread, so the box opens under it whichever message was
|
||||
// clicked -- but it is addressed to whoever was actually being answered
|
||||
state.replyTo = comment.parentId ?? comment.id
|
||||
// -> The box opens at the bottom of the thread, under the replies it is about to join
|
||||
state.collapsed[state.replyTo] = false
|
||||
state.replyToName = comment.authorName
|
||||
/*
|
||||
Started with a mention of whoever is being answered, where they have a handle to be mentioned by
|
||||
@@ -627,6 +658,11 @@ function startNewComment() {
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
window.addEventListener('hashchange', onHashChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('hashchange', onHashChange)
|
||||
})
|
||||
|
||||
// EXPOSED
|
||||
|
||||
@@ -97,6 +97,12 @@
|
||||
<w-item-label v-if="entry.data.excerpt" caption class="inbox-excerpt">
|
||||
“{{ entry.data.excerpt }}”
|
||||
</w-item-label>
|
||||
<!-- -> A comment on a page behind a password the reader has not entered: not quoted,
|
||||
and said so, since the page is where it can be read once it is unlocked -->
|
||||
<w-item-label v-else-if="entry.data.excerptWithheld" caption>
|
||||
<w-icon name="la:lock" size="1.1em" />
|
||||
{{ t('notifications.excerptWithheld') }}
|
||||
</w-item-label>
|
||||
<w-item-label caption>
|
||||
<!-- -> Anchored on the date itself, or WTooltip climbs to the row's `.w-item` -->
|
||||
<span data-tooltip-anchor>
|
||||
|
||||
@@ -1058,11 +1058,14 @@ const canCreatePage = computed(
|
||||
/*
|
||||
Whether this page has a talk page to switch to.
|
||||
|
||||
Four things, and all four have to hold. The site must be using the wiki's OWN comments provider --
|
||||
Five things, and all five have to hold. The site must be using the wiki's OWN comments provider --
|
||||
every other one is a widget under the article, not a second view of the page. The page must take
|
||||
comments at all, which is the switch in its properties dialog. The reader must hold `read:comments`
|
||||
HERE, from the page rules rather than from the group-wide list, since that is what the endpoint
|
||||
behind the tab will check. And the page has to exist: an empty path has nothing to discuss.
|
||||
behind the tab will check. The page has to exist: an empty path has nothing to discuss. And it must
|
||||
not be waiting for its password: the discussion is behind that with the body, and the server
|
||||
refuses it until the page is unlocked -- after which the page is loaded again, unlocked, and the tab
|
||||
is there.
|
||||
|
||||
With no tab, the article is simply the view, which is why everything below tests
|
||||
`activeView` rather than the view alone -- see the computed below it.
|
||||
@@ -1072,6 +1075,7 @@ const showTalkTab = computed(
|
||||
siteStore.comments.isBuiltIn &&
|
||||
pageStore.allowComments &&
|
||||
!pageStore.notFound &&
|
||||
!pageStore.isLocked &&
|
||||
!editorStore.isActive &&
|
||||
// -> A blog's front page is a listing rather than an article: there is nothing here to discuss,
|
||||
// and the discussion a reader wants belongs on the post they are reading
|
||||
@@ -1396,16 +1400,22 @@ async function switchView(view) {
|
||||
/**
|
||||
* The view the current URL asks for.
|
||||
*
|
||||
* `#talk` opens the discussion instead of the article -- what a link to a comment, or to the talk
|
||||
* page of an article, has to be able to say -- and `#links` opens the list of what points here. Read
|
||||
* from `window.location` rather than from the route, so that it answers the same before the router
|
||||
* has resolved anything and when the fragment is changed from outside the app.
|
||||
* `#talk` opens the discussion instead of the article -- what a link to the talk page of an article
|
||||
* has to be able to say -- and so does `#comment-<id>`, the link to one comment in it that its Copy
|
||||
* Link button hands out (`PageComment.vue`), which is then scrolled to like any other anchor. Only
|
||||
* with a uuid after it, since a heading called "Comment Guidelines" is `#comment-guidelines`. `#links`
|
||||
* opens the list of what points here. Read from `window.location` rather than from the route, so that
|
||||
* it answers the same before the router has resolved anything and when the fragment is changed from
|
||||
* outside the app.
|
||||
*
|
||||
* A page with no such view to show simply stays on the article: `activeView` is what is drawn, so a
|
||||
* fragment naming a view this reader does not have is ignored rather than blanking the column.
|
||||
*/
|
||||
function viewFromHash() {
|
||||
const view = window.location.hash.slice(1)
|
||||
if (/^comment-[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(view)) {
|
||||
return 'talk'
|
||||
}
|
||||
return view === 'talk' || view === 'links' ? view : 'article'
|
||||
}
|
||||
|
||||
|
||||
@@ -73,9 +73,10 @@
|
||||
<!--
|
||||
CONTRIBUTIONS
|
||||
=============
|
||||
Two questions of the same person -- what they started and what they touched last -- so the
|
||||
two tabs are two searches differing only in which column they filter on and which date they
|
||||
order and display by.
|
||||
Three questions of the same person. What they started and what they touched last are two
|
||||
searches differing only in which column they filter on and which date they order and display
|
||||
by; what they said in discussions is a list of comments, each leading to the Talk view of its
|
||||
page and drawn as a line or two of what was said rather than as a page.
|
||||
-->
|
||||
<w-card v-if="!state.notFound" class="layout-userprofile-card mt-6">
|
||||
<w-card-section>
|
||||
@@ -95,7 +96,44 @@
|
||||
class="p-6 text-center">
|
||||
<em class="text-grey">{{ tab.empty }}</em>
|
||||
</div>
|
||||
<w-list separator>
|
||||
<w-list v-if="tab.name === `comments`" separator>
|
||||
<w-item
|
||||
v-for="item of lists.comments.results"
|
||||
:key="item.id"
|
||||
clickable
|
||||
:to="commentUrl(item)">
|
||||
<w-item-section avatar>
|
||||
<w-avatar color="primary" text-color="white" rounded>
|
||||
<w-icon :name="item.parentId ? `la:reply` : `la:comment`" size="24px" />
|
||||
</w-avatar>
|
||||
</w-item-section>
|
||||
<w-item-section>
|
||||
<w-item-label
|
||||
v-if="item.preview"
|
||||
class="layout-userprofile-comment"
|
||||
lines="2"
|
||||
>{{ item.preview }}</w-item-label
|
||||
>
|
||||
<w-item-label v-else class="layout-userprofile-comment text-grey">
|
||||
<em>{{ t('userProfile.commentAnnotationsOnly', item.annotationCount) }}</em>
|
||||
</w-item-label>
|
||||
<w-item-label caption>{{
|
||||
item.parentId
|
||||
? t('userProfile.replyOn', { title: item.pageTitle })
|
||||
: t('userProfile.commentOn', { title: item.pageTitle })
|
||||
}}</w-item-label>
|
||||
<w-item-label class="text-grey" caption>{{
|
||||
pageUrl({ locale: item.pageLocale, path: item.pagePath })
|
||||
}}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side top>
|
||||
<div class="text-caption text-right">
|
||||
{{ userStore.formatDateTime(t, item.createdAt) }}
|
||||
</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
<w-list v-else separator>
|
||||
<w-item
|
||||
v-for="item of lists[tab.name].results"
|
||||
:key="item.id"
|
||||
@@ -139,7 +177,7 @@
|
||||
color="primary"
|
||||
:label="t(`userProfile.loadMore`)"
|
||||
:disable="lists[tab.name].loading > 0"
|
||||
@click="loadMoreCurrent" />
|
||||
@click="fetchCurrent" />
|
||||
</div>
|
||||
</w-tab-panel>
|
||||
</w-tab-panels>
|
||||
@@ -168,6 +206,7 @@ import HeaderNav from '@/components/HeaderNav.vue'
|
||||
import FooterNav from '@/components/FooterNav.vue'
|
||||
import MainOverlayDialog from '@/components/MainOverlayDialog.vue'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { renderComment } from '@/renderers/comment'
|
||||
|
||||
/** How many pages one tab fetches at a time. Load More asks for the next batch of the same size. */
|
||||
const PAGE_SIZE = 25
|
||||
@@ -213,13 +252,14 @@ const state = reactive({
|
||||
})
|
||||
|
||||
/**
|
||||
* One list per tab, fetched the first time its tab is looked at rather than both up front: a reader
|
||||
* arriving here is shown the pages this person created, and the other list is a second query for a
|
||||
* tab that may never be opened.
|
||||
* One list per tab, fetched the first time its tab is looked at rather than all up front: a reader
|
||||
* arriving here is shown the pages this person created, and the other lists are more queries for
|
||||
* tabs that may never be opened.
|
||||
*/
|
||||
const lists = reactive({
|
||||
created: { results: [], total: 0, offset: 0, loading: 0, fetched: false },
|
||||
updated: { results: [], total: 0, offset: 0, loading: 0, fetched: false }
|
||||
updated: { results: [], total: 0, offset: 0, loading: 0, fetched: false },
|
||||
comments: { results: [], total: 0, offset: 0, loading: 0, fetched: false }
|
||||
})
|
||||
|
||||
/** The moment the clock line is drawn from, ticked by the interval below. */
|
||||
@@ -233,8 +273,10 @@ const defaultPageIcon = DEFAULT_PAGE_ICON
|
||||
const userId = computed(() => route.params.userId)
|
||||
|
||||
/**
|
||||
* What separates the two tabs, in one place: which column the search filters on, and which of the two
|
||||
* dates it orders and labels the rows by.
|
||||
* What separates the page tabs, in one place: which column the search filters on, and which of the two
|
||||
* dates it orders and labels the rows by. The comments tab is drawn and fetched by its own code, and
|
||||
* is only there on a site whose discussions are the wiki's own -- a third-party provider's comments
|
||||
* are not stored here to be listed.
|
||||
*/
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
@@ -254,7 +296,17 @@ const tabs = computed(() => [
|
||||
filter: 'authorId',
|
||||
dateField: 'updatedAt',
|
||||
orderBy: 'updatedAt'
|
||||
}
|
||||
},
|
||||
...(siteStore.comments.isBuiltIn
|
||||
? [
|
||||
{
|
||||
name: 'comments',
|
||||
label: t('userProfile.comments'),
|
||||
icon: 'la:comments',
|
||||
empty: t('userProfile.noComments')
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
|
||||
const currentTab = computed(() => tabs.value.find((tb) => tb.name === state.tab))
|
||||
@@ -308,7 +360,7 @@ watch(
|
||||
}
|
||||
await fetchProfile()
|
||||
if (!state.notFound) {
|
||||
fetchPages()
|
||||
fetchCurrent()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -319,7 +371,7 @@ watch(
|
||||
() => state.tab,
|
||||
() => {
|
||||
if (!state.notFound && !lists[state.tab].fetched) {
|
||||
fetchPages()
|
||||
fetchCurrent()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -336,6 +388,25 @@ function pageUrl(item) {
|
||||
return `${siteStore.localeUrlPrefix(item.locale)}/${item.path}`
|
||||
}
|
||||
|
||||
/** Where a comment listed here leads: the Talk view of its page, scrolled to it (`pages/Index.vue`). */
|
||||
function commentUrl(item) {
|
||||
return `${pageUrl({ locale: item.pageLocale, path: item.pagePath })}#comment-${item.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* What a comment row shows of the comment: its text with the markdown taken out, run together onto
|
||||
* one line for the row to cut at its second (`lines` on the label).
|
||||
*
|
||||
* Rendered rather than stripped by pattern, so that what is left is what a reader sees on the page --
|
||||
* the renderer already knows what is syntax. Parsed into an inert document to read the text back,
|
||||
* never into the live one: nothing in it is drawn as HTML.
|
||||
*/
|
||||
function commentPreview(excerpt) {
|
||||
const html = renderComment(excerpt)
|
||||
const text = new DOMParser().parseFromString(html, 'text/html').body.textContent ?? ''
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Back to wherever the reader came from, and to this locale's home when there is nowhere to go back
|
||||
* to -- a tab opened straight at this screen, a link followed from somewhere else.
|
||||
@@ -415,8 +486,44 @@ async function fetchPages() {
|
||||
}
|
||||
}
|
||||
|
||||
function loadMoreCurrent() {
|
||||
fetchPages()
|
||||
/**
|
||||
* Fetch one batch of this person's comments, appending to what is already there.
|
||||
*
|
||||
* The server settles which comments this reader may see before it pages -- the page rules of the page
|
||||
* each one is on -- so as with the page lists, every batch but the last is full and the total counts
|
||||
* nothing they were refused.
|
||||
*/
|
||||
async function fetchComments() {
|
||||
const list = lists.comments
|
||||
list.loading++
|
||||
try {
|
||||
const resp = await API_CLIENT.get(`sites/${siteStore.id}/users/${userId.value}/comments`, {
|
||||
searchParams: { offset: list.offset, limit: PAGE_SIZE }
|
||||
}).json()
|
||||
list.results.push(
|
||||
...(resp?.results ?? []).map((r) => ({ ...r, preview: commentPreview(r.excerpt) }))
|
||||
)
|
||||
list.offset += PAGE_SIZE
|
||||
list.total = resp?.total ?? 0
|
||||
list.fetched = true
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('userProfile.commentsLoadingFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
} finally {
|
||||
list.loading--
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the next batch of whichever list the open tab shows. */
|
||||
function fetchCurrent() {
|
||||
if (state.tab === 'comments') {
|
||||
fetchComments()
|
||||
} else {
|
||||
fetchPages()
|
||||
}
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
@@ -506,6 +613,15 @@ onUnmounted(() => {
|
||||
min-width: 15rem;
|
||||
}
|
||||
|
||||
/*
|
||||
A comment row's text, clamped to two lines by the label itself (`lines`). A comment can hold a URL
|
||||
or a run of characters with no break opportunity, which would otherwise widen the row rather than
|
||||
wrap onto the second line.
|
||||
*/
|
||||
&-comment {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
// -> An uploaded avatar is a square image; the container's radius is what makes it a circle
|
||||
overflow: hidden;
|
||||
|
||||
Reference in new issue
Block a user