Skip to content

Commit 73a1f1f

Browse files
authored
feat(agora): handle long rich text (#1090)
Add conversation-specific description limits, reusable rich text empty paragraph normalization, and expandable rich text rendering for long conversation descriptions and statements. Add a dedicated /dev/long-rich-text-disclosure route linked from the component testing page so the behavior can be manually tested using normal conversation components. Change conversation content body storage from varchar to text in the shared schema. Migration files are intentionally not included. Deploy: agora, api, import-worker, math-updater
1 parent 991f1cb commit 73a1f1f

37 files changed

Lines changed: 916 additions & 179 deletions

File tree

services/agora/src/components/editor/Editor.vue

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ import { EditorContent, useEditor } from "@tiptap/vue-3";
118118
import { BubbleMenu } from "@tiptap/vue-3/menus";
119119
import Divider from "primevue/divider";
120120
import { useQuasar } from "quasar";
121-
import sanitizeHtml from "sanitize-html";
122121
import { htmlToCountedText } from "src/shared/shared";
122+
import { processUserGeneratedHtml } from "src/shared-app-api/html";
123123
import { computed, onUnmounted, ref, watch } from "vue";
124124
125125
import EditorToolbarButton from "./EditorToolbarButton.vue";
@@ -267,25 +267,7 @@ const editor = useEditor({
267267
},
268268
// Handle paste to preserve only TipTap-enabled formatting
269269
transformPastedHTML(html) {
270-
// Only allow tags that TipTap supports: b, strong, i, em, strike, s, u, p, br, ul, ol, li
271-
const options: sanitizeHtml.IOptions = {
272-
allowedTags: [
273-
"b",
274-
"strong",
275-
"i",
276-
"em",
277-
"strike",
278-
"s",
279-
"u",
280-
"p",
281-
"br",
282-
"ul",
283-
"ol",
284-
"li",
285-
],
286-
allowedAttributes: {},
287-
};
288-
return sanitizeHtml(html, options);
270+
return processUserGeneratedHtml(html, false, "input");
289271
},
290272
handleKeyDown(_view, event) {
291273
if (props.submitOnEnter && event.key === "Enter" && !event.shiftKey) {

services/agora/src/components/post/list/OpinionListItem.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
:html-body="opinionItem.opinion"
4747
:compact-mode="false"
4848
:enable-links="false"
49+
:collapsible="false"
4950
/>
5051
</div>
5152

services/agora/src/components/post/maxdiff/MaxDiffItemListSection.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
:html-body="item.title"
4545
:compact-mode="true"
4646
:enable-links="false"
47+
content-role="title"
4748
/>
4849
<div v-if="item.score !== null" class="item-meta">
4950
<div class="score-bar-container">

services/agora/src/components/post/maxdiff/MaxDiffStatementDialog.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
:html-body="title"
1616
:compact-mode="false"
1717
:enable-links="false"
18+
content-role="title"
1819
/>
1920
<ZKHtmlContent
2021
v-if="htmlBody"

services/agora/src/components/post/maxdiff/MaxDiffVotingTab.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@
134134
:html-body="itemContentMap.get(slugId) ?? slugId"
135135
:compact-mode="false"
136136
:enable-links="false"
137+
content-role="title"
137138
/>
138139
<span v-if="itemBySlugId.get(slugId)?.body" class="candidate-body-inline">
139140
— {{ stripHtml(itemBySlugId.get(slugId)?.body ?? "") }}

services/agora/src/components/ui-library/ZKHtmlContent.i18n.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { SupportedDisplayLanguageCodes } from "src/shared/languages";
33
export interface ZKHtmlContentTranslations {
44
postContentPreview: string;
55
postContent: string;
6+
readMore: string;
7+
showLess: string;
68
}
79

810
export const zkHtmlContentTranslations: Record<
@@ -12,39 +14,67 @@ export const zkHtmlContentTranslations: Record<
1214
en: {
1315
postContentPreview: "Post content preview",
1416
postContent: "Post content",
17+
readMore: "Read more",
18+
showLess: "Show less",
1519
},
1620
ar: {
1721
postContentPreview: "معاينة محتوى المنشور",
1822
postContent: "محتوى المنشور",
23+
readMore: "اقرأ المزيد",
24+
showLess: "عرض أقل",
1925
},
2026
es: {
2127
postContentPreview: "Vista previa del contenido",
2228
postContent: "Contenido de la publicación",
29+
readMore: "Leer más",
30+
showLess: "Mostrar menos",
31+
},
32+
fa: {
33+
postContentPreview: "پیش‌نمایش محتوای پست",
34+
postContent: "محتوای پست",
35+
readMore: "بیشتر بخوانید",
36+
showLess: "نمایش کمتر",
2337
},
24-
fa: { postContentPreview: "پیش‌نمایش محتوای پست", postContent: "محتوای پست" },
2538
fr: {
2639
postContentPreview: "Aperçu du contenu",
2740
postContent: "Contenu du post",
41+
readMore: "Lire la suite",
42+
showLess: "Afficher moins",
2843
},
2944
"zh-Hans": {
3045
postContentPreview: "帖子内容预览",
3146
postContent: "帖子内容",
47+
readMore: "阅读更多",
48+
showLess: "收起",
3249
},
3350
"zh-Hant": {
3451
postContentPreview: "帖子內容預覽",
3552
postContent: "帖子內容",
53+
readMore: "閱讀更多",
54+
showLess: "收起",
55+
},
56+
he: {
57+
postContentPreview: "תצוגה מקדימה של תוכן הפוסט",
58+
postContent: "תוכן הפוסט",
59+
readMore: "קראו עוד",
60+
showLess: "הציגו פחות",
3661
},
37-
he: { postContentPreview: "תצוגה מקדימה של תוכן הפוסט", postContent: "תוכן הפוסט" },
3862
ja: {
3963
postContentPreview: "投稿内容プレビュー",
4064
postContent: "投稿内容",
65+
readMore: "続きを読む",
66+
showLess: "表示を減らす",
4167
},
4268
ky: {
4369
postContentPreview: "Жазуу мазмунун алдын ала көрүү",
4470
postContent: "Жазуу мазмуну",
71+
readMore: "Кененирээк окуу",
72+
showLess: "Азыраак көрсөтүү",
4573
},
4674
ru: {
4775
postContentPreview: "Предпросмотр содержимого публикации",
4876
postContent: "Содержимое публикации",
77+
readMore: "Читать дальше",
78+
showLess: "Скрыть",
4979
},
5080
};

services/agora/src/components/ui-library/ZKHtmlContent.vue

Lines changed: 167 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,37 @@
11
<!-- eslint-disable vue/no-v-html -->
22
<template>
3+
<div v-if="isDisclosureEnabled" class="html-content-wrapper">
4+
<div
5+
:id="contentId"
6+
ref="contentRef"
7+
class="textBreak"
8+
:class="{
9+
truncate: compactMode,
10+
'collapsible-content': isDisclosureEnabled,
11+
'collapsible-content--collapsed': isDisclosureEnabled && isCollapsed,
12+
coloredHrefs: !compactMode,
13+
}"
14+
:style="contentStyle"
15+
role="user-content"
16+
:aria-label="compactMode ? t('postContentPreview') : t('postContent')"
17+
@click="handleClick"
18+
v-html="sanitizedHtmlBody"
19+
></div>
20+
21+
<button
22+
v-if="showDisclosureButton"
23+
type="button"
24+
class="disclosure-button"
25+
:aria-expanded="!isCollapsed"
26+
:aria-controls="contentId"
27+
@click.stop="toggleDisclosure"
28+
>
29+
{{ isCollapsed ? t("readMore") : t("showLess") }}
30+
</button>
31+
</div>
32+
333
<span
34+
v-else
435
class="textBreak"
536
:class="{ truncate: compactMode, coloredHrefs: !compactMode }"
637
role="user-content"
@@ -13,23 +44,49 @@
1344
<script setup lang="ts">
1445
import { useComponentI18n } from "src/composables/ui/useComponentI18n";
1546
import { processUserGeneratedHtml } from "src/shared-app-api/html";
16-
import { computed } from "vue";
47+
import {
48+
computed,
49+
nextTick,
50+
onBeforeUnmount,
51+
onMounted,
52+
ref,
53+
useId,
54+
watch,
55+
} from "vue";
1756
1857
import {
1958
type ZKHtmlContentTranslations,
2059
zkHtmlContentTranslations,
2160
} from "./ZKHtmlContent.i18n";
2261
23-
const props = defineProps<{
24-
htmlBody: string;
25-
compactMode: boolean;
26-
enableLinks: boolean;
27-
}>();
62+
type HtmlContentRole = "body" | "title";
63+
64+
const props = withDefaults(
65+
defineProps<{
66+
htmlBody: string;
67+
compactMode: boolean;
68+
enableLinks: boolean;
69+
contentRole?: HtmlContentRole;
70+
collapsible?: boolean;
71+
collapsedLineCount?: number;
72+
}>(),
73+
{
74+
contentRole: "body",
75+
collapsible: true,
76+
collapsedLineCount: 10,
77+
}
78+
);
2879
2980
const { t } = useComponentI18n<ZKHtmlContentTranslations>(
3081
zkHtmlContentTranslations
3182
);
3283
84+
const contentId = `zk-html-content-${useId()}`;
85+
const contentRef = ref<HTMLElement | null>(null);
86+
const isCollapsed = ref(true);
87+
const isOverflowing = ref(false);
88+
let resizeObserver: ResizeObserver | undefined;
89+
3390
const sanitizedHtmlBody = computed(() => {
3491
try {
3592
return processUserGeneratedHtml(props.htmlBody, props.enableLinks);
@@ -47,6 +104,49 @@ const sanitizedHtmlBody = computed(() => {
47104
}
48105
});
49106
107+
const isDisclosureEnabled = computed(
108+
() => props.collapsible && props.contentRole === "body" && !props.compactMode
109+
);
110+
111+
const showDisclosureButton = computed(
112+
() => isDisclosureEnabled.value && isOverflowing.value
113+
);
114+
115+
const contentStyle = computed(() => ({
116+
"--collapsed-line-count": props.collapsedLineCount.toString(),
117+
}));
118+
119+
function observeContentElement(contentElement: HTMLElement | null): void {
120+
resizeObserver?.disconnect();
121+
if (resizeObserver !== undefined && contentElement !== null) {
122+
resizeObserver.observe(contentElement);
123+
}
124+
}
125+
126+
async function updateOverflowState(): Promise<void> {
127+
await nextTick();
128+
const contentElement = contentRef.value;
129+
if (contentElement === null || !isDisclosureEnabled.value) {
130+
isOverflowing.value = false;
131+
return;
132+
}
133+
134+
if (!isCollapsed.value) {
135+
return;
136+
}
137+
138+
isOverflowing.value =
139+
contentElement.scrollHeight > contentElement.clientHeight + 1;
140+
}
141+
142+
function toggleDisclosure(): void {
143+
isCollapsed.value = !isCollapsed.value;
144+
if (isCollapsed.value) {
145+
contentRef.value?.scrollIntoView({ block: "nearest" });
146+
void updateOverflowState();
147+
}
148+
}
149+
50150
const handleClick = (event: Event) => {
51151
const target = event.target as HTMLElement;
52152
// Check if the clicked element is a link or is inside a link
@@ -56,6 +156,33 @@ const handleClick = (event: Event) => {
56156
event.stopPropagation();
57157
}
58158
};
159+
160+
onMounted(() => {
161+
if (typeof ResizeObserver !== "undefined") {
162+
resizeObserver = new ResizeObserver(() => {
163+
void updateOverflowState();
164+
});
165+
observeContentElement(contentRef.value);
166+
}
167+
void updateOverflowState();
168+
});
169+
170+
onBeforeUnmount(() => {
171+
resizeObserver?.disconnect();
172+
});
173+
174+
watch(
175+
[sanitizedHtmlBody, isDisclosureEnabled, () => props.collapsedLineCount],
176+
() => {
177+
isCollapsed.value = true;
178+
void updateOverflowState();
179+
}
180+
);
181+
182+
watch(contentRef, (contentElement) => {
183+
observeContentElement(contentElement);
184+
void updateOverflowState();
185+
});
59186
</script>
60187

61188
<style lang="scss" scoped>
@@ -66,6 +193,40 @@ const handleClick = (event: Event) => {
66193
word-break: break-word;
67194
}
68195
196+
.html-content-wrapper {
197+
display: block;
198+
}
199+
200+
.collapsible-content {
201+
position: relative;
202+
}
203+
204+
.collapsible-content--collapsed {
205+
overflow: hidden;
206+
display: -webkit-box;
207+
-webkit-line-clamp: var(--collapsed-line-count);
208+
line-clamp: var(--collapsed-line-count);
209+
-webkit-box-orient: vertical;
210+
}
211+
212+
.disclosure-button {
213+
display: flex;
214+
align-items: center;
215+
padding: 0;
216+
width: fit-content;
217+
margin-block: 0.5rem;
218+
border: 0;
219+
background: transparent;
220+
color: $primary;
221+
font: inherit;
222+
font-weight: var(--font-weight-medium);
223+
cursor: pointer;
224+
}
225+
226+
.disclosure-button:hover {
227+
text-decoration: underline;
228+
}
229+
69230
:deep(p) {
70231
margin-bottom: 0.5rem;
71232
}

services/agora/src/composables/conversation/draft/conversationDraft.schema.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* - newConversationDrafts store (persistence validation)
77
*/
88

9-
import { MAX_LENGTH_BODY, MAX_LENGTH_TITLE } from "src/shared/shared";
9+
import { MAX_LENGTH_CONVERSATION_BODY, MAX_LENGTH_TITLE } from "src/shared/shared";
1010
import {
1111
zodConversationType,
1212
zodEventSlug,
@@ -174,5 +174,5 @@ export type ConversationImportType = z.infer<typeof zodConversationImportType>;
174174

175175
export const VALIDATION_CONSTANTS = {
176176
MAX_LENGTH_TITLE,
177-
MAX_LENGTH_BODY,
177+
MAX_LENGTH_BODY: MAX_LENGTH_CONVERSATION_BODY,
178178
} as const;

0 commit comments

Comments
 (0)