Skip to content

Commit ec3aac1

Browse files
committed
Add support for leaving recommendation hearts instead of full comments
1 parent a57b7b0 commit ec3aac1

File tree

12 files changed

+227
-440
lines changed

12 files changed

+227
-440
lines changed

lang/en-nz/view/work.quilt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
title: Recommendations
55
content/empty: No recommendations yet...
66
content/end: No more recommendations
7-
action/add/label: Recommend this work:
7+
action/added/label: You recommend this work!
8+
action/add/label: Click to recommend this work!
89
action/no-add/label: Read more of this work to leave a recommendation!
10+
editor/placeholder: Want to add more detail?
11+
reactions/plus: +{0} additional recommendations!
12+
other-recommendations-title: Other recommendations:
913

1014
### chapters
1115
title=shared/term/chapters

package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"@types/serve-static": "1.15.7",
2020
"@types/ws": "8.5.13",
2121
"ansicolor": "1.1.100",
22-
"chiri": "github:fluff4me/chiri#d381ef046f789e561983f077e0242bce150b09da",
22+
"chiri": "github:fluff4me/chiri#6f49a9bcaa77e450cc599a61f44e83079bd303f3",
2323
"chokidar": "3.5.3",
2424
"cssnano": "7.0.6",
2525
"del": "6.1.1",
@@ -28,7 +28,7 @@
2828
"escape-html": "1.0.3",
2929
"fs-extra": "10.1.0",
3030
"https-localhost": "4.7.1",
31-
"lint": "github:fluff4me/lint#c0ccd500c35a06157376a78ac911147be44c27cd",
31+
"lint": "github:fluff4me/lint#936252f6c9ecfcf0ddcd48ef2104314af5841686",
3232
"parseurl": "1.3.3",
3333
"postcss": "8.5.3",
3434
"serve-static": "1.16.2",
@@ -40,5 +40,11 @@
4040
"typescript-eslint": "8.26.0",
4141
"weaving": "github:chirivulpes/weaving#fc532b3e2025f424747e382caf66ad1661932c9a",
4242
"ws": "8.18.0"
43+
},
44+
"pnpm": {
45+
"overrides": {
46+
"express": "-",
47+
"ts-node>diff": "8.0.3"
48+
}
4349
}
4450
}

pnpm-lock.yaml

Lines changed: 22 additions & 406 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import Endpoint from 'endpoint/Endpoint'
2+
3+
export default Endpoint('/v2/reactions/work/{author_vanity}/{work_vanity}/add', 'post').noResponse()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import Endpoint from 'endpoint/Endpoint'
2+
3+
export default Endpoint('/v2/reactions/work/{author_vanity}/{work_vanity}/delete', 'post').noResponse()

src/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@
2525
}
2626
},
2727
"devDependencies": {
28-
"api.fluff4.me": "1.0.1145"
28+
"api.fluff4.me": "1.0.1157"
2929
}
3030
}

src/pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ui/component/core/TextEditor.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -764,15 +764,15 @@ const markdownParser = new MarkdownParser(schema, markdown, Objects.filterNullis
764764
},
765765

766766
...Object.entries(markdownHTMLNodeRegistry)
767-
.toObject(([tokenType, spec]) => [tokenType, ({
767+
.toObject(([tokenType, spec]) => [tokenType, {
768768
block: tokenType,
769769
getAttrs: token => (token as FluffToken).nodeAttrs ?? Object.fromEntries(token.attrs ?? []),
770-
} satisfies ParseSpec)]),
770+
} satisfies ParseSpec]),
771771
...Object.entries(markdownHTMLMarkRegistry)
772-
.toObject(([tokenType, spec]) => [tokenType, ({
772+
.toObject(([tokenType, spec]) => [tokenType, {
773773
mark: tokenType,
774774
getAttrs: token => (token as FluffToken).nodeAttrs ?? Object.fromEntries(token.attrs ?? []),
775-
} satisfies ParseSpec)]),
775+
} satisfies ParseSpec]),
776776
} satisfies Record<string, ParseSpec | undefined>))
777777

778778
const markdownSerializer = new MarkdownSerializer(
@@ -950,6 +950,7 @@ interface TextEditorExtensions {
950950
readonly default: StringApplicator.Optional<this>
951951
readonly content: State<string>
952952
readonly touched: State<boolean>
953+
readonly placeholder: StringApplicator.Optional<this>
953954
document?: Input
954955
mirror?: EditorView
955956
useMarkdown (): string
@@ -1407,6 +1408,12 @@ const TextEditor = Object.assign(Component.Builder((component): TextEditor => {
14071408
default: StringApplicator(editor, value => loadFromMarkdown(value)),
14081409
toolbar,
14091410
editor: actualEditor,
1411+
placeholder: StringApplicator(editor, value => {
1412+
editor.document
1413+
?.style.bind(content.map(editor, content => !content.trim()), 'text-editor-document--empty')
1414+
.style.setVariable('text-editor-placeholder', value && `"${value.replace(/"/g, '\\"')}"`)
1415+
return editor
1416+
}),
14101417
setRequired (required = true) {
14111418
editor.style.toggle(required, 'text-editor--required')
14121419
editor.required.asMutable?.setValue(required)

src/ui/view/ChapterView.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ export default ViewDefinition({
382382
const supporterReactions = chapterState.value.supporter_reactions ??= []
383383
supporterReactions.push({
384384
author: Session.Auth.author.value!.vanity,
385-
reaction_type: 'heart',
385+
reaction_type: 'supporter_love',
386386
})
387387

388388
chapterState.emit()

src/ui/view/WorkView.ts

Lines changed: 123 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import EndpointChapters$authorVanity$workVanity from 'endpoint/chapters/$author_
44
import EndpointHistoryBookmarks$authorVanity$workVanityDeleteFurthestRead from 'endpoint/history/bookmarks/$author_vanity/$work_vanity/delete/EndpointHistoryBookmarks$authorVanity$workVanityDeleteFurthestRead'
55
import EndpointHistoryBookmarks$authorVanity$workVanityDeleteLastRead from 'endpoint/history/bookmarks/$author_vanity/$work_vanity/delete/EndpointHistoryBookmarks$authorVanity$workVanityDeleteLastRead'
66
import EndpointHistoryWork$authorVanity$workVanityAdd from 'endpoint/history/work/$author_vanity/$work_vanity/EndpointHistoryWork$authorVanity$workVanityAdd'
7+
import EndpointReactionsWork$authorVanity$workVanityAdd from 'endpoint/reactions/work/$author_vanity/$work_vanity/EndpointReactionsWork$authorVanity$workVanityAdd'
8+
import EndpointReactionsWork$authorVanity$workVanityDelete from 'endpoint/reactions/work/$author_vanity/$work_vanity/EndpointReactionsWork$authorVanity$workVanityDelete'
79
import EndpointWorks$authorVanity$workVanityGet from 'endpoint/works/$author_vanity/$work_vanity/EndpointWorks$authorVanity$workVanityGet'
810
import Chapters from 'model/Chapters'
911
import PagedListData from 'model/PagedListData'
@@ -12,6 +14,7 @@ import Tags from 'model/Tags'
1214
import Works from 'model/Works'
1315
import Component from 'ui/Component'
1416
import ActionBlock from 'ui/component/ActionBlock'
17+
import AuthorLink from 'ui/component/AuthorLink'
1518
import Chapter from 'ui/component/Chapter'
1619
import type { CommentData } from 'ui/component/Comment'
1720
import Comment from 'ui/component/Comment'
@@ -20,11 +23,14 @@ import CommentTree from 'ui/component/CommentTree'
2023
import Block from 'ui/component/core/Block'
2124
import Button from 'ui/component/core/Button'
2225
import GradientText from 'ui/component/core/ext/GradientText'
26+
import Heading from 'ui/component/core/Heading'
27+
import Icon from 'ui/component/core/Icon'
2328
import Link from 'ui/component/core/Link'
2429
import Paginator from 'ui/component/core/Paginator'
2530
import Placeholder from 'ui/component/core/Placeholder'
2631
import Slot from 'ui/component/core/Slot'
2732
import Tabinator, { Tab } from 'ui/component/core/Tabinator'
33+
import Reaction from 'ui/component/Reaction'
2834
import Work from 'ui/component/Work'
2935
import DynamicDestination from 'ui/utility/DynamicDestination'
3036
import Viewport from 'ui/utility/Viewport'
@@ -316,23 +322,61 @@ export default ViewDefinition({
316322
.setAestheticLevel(4)
317323
.text.use('view/work/comments/title')
318324
)
319-
.tweak(block => block.content.append(Slot().use([commentState, workData, ageRestricted], (slot, thread, work, ageRestricted) => {
325+
.tweak(block => block.content.append(Slot().use([commentState, workData, ageRestricted, Session.Auth.author], (slot, thread, work, ageRestricted, self) => {
320326
if (!thread || ageRestricted)
321327
return
322328

329+
const MAX_RECOMMENDATIONS_DISPLAY = 8
330+
// work.recommendation_reactions_count = 35
331+
const ReactionRecommendations = () => Component()
332+
.style('view-type-work-comment-block-reactions')
333+
.append(...work.recommendation_reactions
334+
.slice(0, MAX_RECOMMENDATIONS_DISPLAY)
335+
.map(reaction => {
336+
if (reaction.author === self?.vanity)
337+
return undefined
338+
339+
const author = workData.value.synopsis.mentions.find(mention => mention.vanity === reaction.author)
340+
if (!author)
341+
return undefined
342+
343+
return reaction.author === self?.vanity ? undefined
344+
: Component()
345+
.style('view-type-work-comment-block-reactions-instance')
346+
.append(reaction.reaction_type === 'supporter_love'
347+
? Icon('supporter-heart')
348+
.style('view-type-chapter-block-supporter-reaction')
349+
.and(GradientText, 'heart-gradient', '115deg')
350+
.useGradient(author.supporter?.username_colours)
351+
: Icon('heart')
352+
)
353+
.append(AuthorLink(author)
354+
.style('view-type-work-comment-block-reactions-author-link')
355+
)
356+
})
357+
)
358+
.append(work.recommendation_reactions_count > MAX_RECOMMENDATIONS_DISPLAY && Component()
359+
.style('view-type-work-comment-block-reactions-plus')
360+
.text.use(quilt => quilt['view/work/comments/reactions/plus'](work.recommendation_reactions_count - MAX_RECOMMENDATIONS_DISPLAY))
361+
)
362+
363+
if (!self)
364+
ReactionRecommendations()
365+
.appendTo(slot)
366+
323367
const isOwnWork = Session.Auth.loggedInAs(slot, thread.threadAuthor)
324368
const commentsRenderDefinition: CommentTreeRenderDefinition = {
325369
simpleTimestamps: true,
326370
onCommentsUpdate (comments) {
327-
const ownRecommendation = comments.find(comment => comment.author === Session.Auth.author.value?.vanity && !comment.edit)
371+
const ownRecommendation = comments.find(comment => comment.author === self?.vanity && !comment.edit)
328372
if (!work.recommendation && ownRecommendation) {
329373
work.recommendation = ownRecommendation
330374
workData.emit()
331375
}
332376
},
333377
shouldSkipComment (data) {
334378
return true
335-
&& data.author === Session.Auth.author.value?.vanity
379+
&& data.author === self?.vanity
336380
&& !data.edit
337381
},
338382
onRenderComment (comment, data) {
@@ -346,6 +390,7 @@ export default ViewDefinition({
346390

347391
comment.editor
348392
.setMinimal(tabletMode.falsy)
393+
.placeholder.use('view/work/comments/editor/placeholder')
349394
.hint.use()
350395

351396
if (data.comment_id)
@@ -354,41 +399,93 @@ export default ViewDefinition({
354399

355400
// this is the new comment editor (only possible on root)
356401
// dynamically replace the editor with hints or the existing recommendation if applicable
357-
const existingRecommendation = work.recommendation as CommentData | undefined
358-
const canRecommend = !existingRecommendation && !work.bookmarks?.can_recommend ? State(false)
402+
const existingRecommendationComment = work.recommendation as CommentData | undefined
403+
const canRecommend = !existingRecommendationComment && !work.bookmarks?.can_recommend ? State(false)
359404
: isOwnWork.falsy
360-
const noExistingRecommendation = State(!existingRecommendation)
361-
const showCommentEditor = State.Every(comment, canRecommend, noExistingRecommendation)
362-
const showCommentHint = State.Every(comment, canRecommend.falsy, isOwnWork.falsy, noExistingRecommendation)
405+
const noExistingRecommendationComment = State(!existingRecommendationComment)
363406

364-
comment.author?.text.use('view/work/comments/action/add/label')
365-
.style('view-type-work-comment-editor-author-hint')
407+
const reacting = State(false)
408+
const reacted = State(!!work.recommendation_reacted)
409+
410+
const showCommentEditor = State.Every(comment, canRecommend, noExistingRecommendationComment, reacted)
411+
const showCommentHint = State.Every(comment, canRecommend.falsy, isOwnWork.falsy, noExistingRecommendationComment, reacted.falsy)
412+
413+
comment.author?.remove()
366414
comment.onRooted(() => {
367415
const parent = comment.parent!
368416

417+
ReactionRecommendations()
418+
.prependTo(parent)
419+
420+
const hasRecommended = State.Some(comment, reacted, noExistingRecommendationComment.falsy)
421+
const workHasOtherRecommendationReactions = hasRecommended.map(comment, hasRecommended => work.recommendation_reactions_count > +hasRecommended)
422+
Heading()
423+
.setAestheticLevel(6)
424+
.text.use('view/work/comments/other-recommendations-title')
425+
.prependToWhen(workHasOtherRecommendationReactions, parent)
426+
369427
// show comment editor only when able to recommend & no existing recommendation
370428
comment.prependToWhen(showCommentEditor, parent)
371429

430+
Component()
431+
.style('view-type-work-comment-hint', 'view-type-work-comment-hint--borderless')
432+
.append((self?.supporter?.tier
433+
? Reaction('supporter_love', 0, reacted, reacting)
434+
.and(GradientText, 'heart-gradient', '115deg')
435+
.useGradient(Session.Auth.author.map(slot, author => author?.supporter?.username_colours))
436+
: Reaction('love', 0, reacted, reacting)
437+
)
438+
.style('view-type-work-comment-hint-reaction-button')
439+
.event.subscribe('click', async () => {
440+
if (reacting.value)
441+
return
442+
443+
reacting.value = true
444+
const endpoint = reacted.value
445+
? EndpointReactionsWork$authorVanity$workVanityDelete
446+
: EndpointReactionsWork$authorVanity$workVanityAdd
447+
448+
const response = await endpoint.query({ params: Works.reference(work) })
449+
if (toast.handleError(response)) {
450+
reacting.value = false
451+
return
452+
}
453+
454+
reacted.value = !reacted.value
455+
reacting.value = false
456+
})
457+
)
458+
.append(Link(undefined)
459+
.and(GradientText)
460+
.useGradient(self?.supporter?.username_colours)
461+
.style('author-link')
462+
.text.bind(reacted.map(comment, reacted => quilt => quilt[reacted
463+
? 'view/work/comments/action/added/label'
464+
: 'view/work/comments/action/add/label'
465+
]()))
466+
)
467+
.prependToWhen(State.Every(comment, showCommentHint.falsy, noExistingRecommendationComment), parent)
468+
372469
// show hint when unable to recommend & no existing recommendation
373470
Link(undefined)
374471
.and(GradientText)
375-
.useGradient(Session.Auth.author.value?.supporter?.username_colours)
472+
.useGradient(self?.supporter?.username_colours)
376473
.style('view-type-work-comment-hint', 'author-link')
377474
.text.use('view/work/comments/action/no-add/label')
378475
.prependToWhen(showCommentHint, parent)
379476

380477
// otherwise show existing recommendation
381-
if (existingRecommendation) {
382-
const existingRecommendationState = State([existingRecommendation])
478+
if (existingRecommendationComment) {
479+
const existingRecommendationState = State([existingRecommendationComment])
383480
existingRecommendationState.subscribeManual(([existingRecommendation]) => {
384481
if (!existingRecommendation) {
385482
work.recommendation = undefined
386483
workData.emit()
387484
}
388485
})
389486
Comment(
390-
{ threadAuthor: thread.threadAuthor, comments: existingRecommendationState, authors: State(existingRecommendation.body?.mentions ?? []) },
391-
existingRecommendation,
487+
{ threadAuthor: thread.threadAuthor, comments: existingRecommendationState, authors: State(existingRecommendationComment.body?.mentions ?? []) },
488+
existingRecommendationComment,
392489
undefined,
393490
commentsRenderDefinition,
394491
)
@@ -397,11 +494,17 @@ export default ViewDefinition({
397494
})
398495
},
399496
onNoComments (slot) {
400-
Placeholder()
401-
.style('view-type-work-comment-placeholder', 'view-type-work-comment-placeholder--empty')
402-
.style.bind(isOwnWork, 'view-type-work-comment-placeholder--empty--is-own-work')
403-
.text.use('view/work/comments/content/empty')
404-
.appendTo(slot)
497+
if (!work.recommendation_reactions.length)
498+
Placeholder()
499+
.style('view-type-work-comment-placeholder', 'view-type-work-comment-placeholder--empty')
500+
.style.bind(isOwnWork, 'view-type-work-comment-placeholder--empty--is-own-work')
501+
.text.use('view/work/comments/content/empty')
502+
.appendTo(slot)
503+
else
504+
Placeholder()
505+
.style('view-type-work-comment-placeholder', 'view-type-work-comment-placeholder--empty', 'view-type-work-comment-placeholder--end')
506+
.text.use('view/work/comments/content/end')
507+
.appendTo(slot)
405508
},
406509
onCommentsEnd (slot) {
407510
Placeholder()

0 commit comments

Comments
 (0)