@amityco/social-plus-vise 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +75 -2
  2. package/README.md +23 -10
  3. package/dist/capabilities.js +35 -4
  4. package/dist/entryState.js +71 -0
  5. package/dist/experience.js +70 -0
  6. package/dist/explore.js +51 -0
  7. package/dist/flow.js +382 -0
  8. package/dist/humanFormat.js +251 -0
  9. package/dist/intake.js +117 -0
  10. package/dist/intelligence/placement.js +2 -1
  11. package/dist/outcomes.js +141 -42
  12. package/dist/productExpectations.js +15 -0
  13. package/dist/requestReadiness.js +99 -0
  14. package/dist/server.js +675 -56
  15. package/dist/sidecar.js +5 -0
  16. package/dist/solutionPath.js +2 -2
  17. package/dist/tools/compliance.js +1014 -133
  18. package/dist/tools/creative.js +14 -12
  19. package/dist/tools/debug.js +83 -26
  20. package/dist/tools/design.js +344 -14
  21. package/dist/tools/experienceCompiler.js +1 -1
  22. package/dist/tools/experienceSensors.js +1 -1
  23. package/dist/tools/harness.js +13 -0
  24. package/dist/tools/integration.js +263 -90
  25. package/dist/tools/learning.js +1 -3
  26. package/dist/tools/project.js +987 -72
  27. package/dist/tools/sdkFacts.js +8 -1
  28. package/dist/tools/smoke.js +134 -0
  29. package/dist/tools/uxHarness.js +54 -6
  30. package/dist/uikitCustomization.js +22 -6
  31. package/dist/version.js +7 -3
  32. package/package.json +1 -1
  33. package/packages/intelligence/catalog/catalog.schema.json +1 -1
  34. package/packages/intelligence/catalog/experience-objects.json +2 -2
  35. package/packages/intelligence/catalog/variants.json +24 -0
  36. package/rules/event.yaml +3 -0
  37. package/rules/feed.yaml +251 -0
  38. package/rules/invitation.yaml +4 -0
  39. package/rules/live-data.yaml +110 -0
  40. package/rules/notification-tray.yaml +4 -0
  41. package/rules/poll.yaml +5 -0
  42. package/rules/sdk-lifecycle.yaml +559 -0
  43. package/rules/search.yaml +5 -0
  44. package/rules/security.yaml +12 -0
  45. package/rules/story.yaml +5 -0
  46. package/rules/user-blocking.yaml +5 -0
  47. package/skills/social-plus-vise/SKILL.md +163 -15
package/dist/intake.js ADDED
@@ -0,0 +1,117 @@
1
+ import { hasAnswer } from "./outcomes.js";
2
+ import { availableOptionalCapabilityIds, optionalCapabilityChecklist, } from "./capabilities.js";
3
+ import { DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID } from "./tools/design.js";
4
+ export const CLARITY_GATES = [
5
+ {
6
+ id: DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID,
7
+ axis: "design",
8
+ blocking: true,
9
+ sufficient: (c) => c.designReview?.status !== "needs-confirmation" ||
10
+ hasAnswer(c.ctx.answers, DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID),
11
+ question: (c) => ({
12
+ id: DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID,
13
+ question: `Open ${c.designReview?.previewPath} and confirm whether this design preview/contract should be used for the social UI.`,
14
+ why: "A design contract must be explicitly accepted before Vise can feed it forward or let the agent claim design conformance. If it is wrong, reject it and provide the correct design source.",
15
+ required: true,
16
+ blocksImplementationWhenMissing: true,
17
+ options: ["yes", "no"],
18
+ }),
19
+ },
20
+ {
21
+ id: "design_source",
22
+ axis: "design",
23
+ blocking: true,
24
+ sufficient: (c) => c.designReview?.status !== "rejected" || hasAnswer(c.ctx.answers, "design_source"),
25
+ question: () => ({
26
+ id: "design_source",
27
+ question: "What design source should replace the rejected preview/contract?",
28
+ why: "The current design preview was rejected, so Vise must not use it. Provide a prototype, screenshot, theme/token file, or explicitly say to proceed without a design-conformance claim.",
29
+ required: true,
30
+ blocksImplementationWhenMissing: true,
31
+ }),
32
+ },
33
+ {
34
+ id: "design_source",
35
+ axis: "design",
36
+ blocking: true,
37
+ sufficient: (c) => !(c.ctx.mentionsDesign && c.ctx.designSignals.length === 0) || hasAnswer(c.ctx.answers, "design_source"),
38
+ question: () => ({
39
+ id: "design_source",
40
+ question: "Where are the app's design tokens, theme, or reusable UI components defined?",
41
+ why: "The user asked to match the existing design, and no local design source was detected.",
42
+ required: true,
43
+ blocksImplementationWhenMissing: true,
44
+ }),
45
+ },
46
+ {
47
+ id: "confirm_design_source",
48
+ axis: "design",
49
+ blocking: false,
50
+ sufficient: (c) => !(c.ctx.mentionsDesign && c.ctx.designSignals.length > 0) || hasAnswer(c.ctx.answers, "confirm_design_source"),
51
+ question: (c) => ({
52
+ id: "confirm_design_source",
53
+ question: `Should the social UI use the detected design source(s): ${c.ctx.designSignals.map((signal) => signal.file).join(", ")}?`,
54
+ why: "Vise found likely design evidence, but the user or host agent should confirm it is the right source before UI edits.",
55
+ required: true,
56
+ blocksImplementationWhenMissing: false,
57
+ options: ["yes", "use another source"],
58
+ }),
59
+ },
60
+ {
61
+ id: "primary_action_token",
62
+ axis: "design",
63
+ blocking: false,
64
+ sufficient: (c) => !(c.designBrief &&
65
+ c.designReview?.status === "accepted" &&
66
+ (c.outcome === "add-feed" || c.outcome === "add-chat") &&
67
+ !c.designBrief.roles.some((role) => role.role === "primaryAction") &&
68
+ !hasAnswer(c.ctx.answers, "primary_action_token")),
69
+ question: () => ({
70
+ id: "primary_action_token",
71
+ question: "Which design token (or color value) should be used as the primary action color? No primary-action token was confidently identified in the design contract.",
72
+ why: "A primary action colour is needed for interactive elements (composer button, own-message bubble). Without a confident token, the agent must guess or omit it.",
73
+ required: false,
74
+ blocksImplementationWhenMissing: false,
75
+ }),
76
+ },
77
+ {
78
+ id: "feed_optional_capabilities",
79
+ axis: "feature",
80
+ blocking: false,
81
+ sufficient: (c) => !(c.outcome === "add-feed" && c.optionalChoices.length > 0 && !hasAnswer(c.ctx.answers, "feed_optional_capabilities")),
82
+ question: (c) => ({
83
+ id: "feed_optional_capabilities",
84
+ question: `Which optional feed capabilities should be in scope on ${c.ctx.platform}: ${c.optionalChoices.map((capability) => capability.id).join(", ")}, or none?`,
85
+ why: "These capabilities are useful for full feeds, but should become enforceable only after the customer explicitly opts in. Vise only lists capabilities that appear available in the platform SDK surface; unsupported capabilities must be treated as out of scope or confirmed from docs.",
86
+ required: false,
87
+ blocksImplementationWhenMissing: false,
88
+ options: ["none", ...c.optionalChoices.map((capability) => capability.id)],
89
+ }),
90
+ },
91
+ ];
92
+ export function assembleIntake(args) {
93
+ const availableOptionalIds = availableOptionalCapabilityIds(args.capabilityAvailability);
94
+ const optionalChoices = args.outcome === "add-feed" ? optionalCapabilityChecklist(args.outcome, availableOptionalIds) : [];
95
+ const clarityCtx = {
96
+ ctx: args.ctx,
97
+ outcome: args.outcome,
98
+ designBrief: args.designBrief,
99
+ designReview: args.designReview,
100
+ optionalChoices,
101
+ };
102
+ const questions = [...args.outcomeQuestions];
103
+ for (const gate of CLARITY_GATES) {
104
+ if (args.suppressGateIds?.includes(gate.id)) {
105
+ continue;
106
+ }
107
+ if (!gate.sufficient(clarityCtx)) {
108
+ questions.push(gate.question(clarityCtx));
109
+ }
110
+ }
111
+ const remainingBlocking = questions.filter((question) => question.blocksImplementationWhenMissing).length;
112
+ return {
113
+ questions,
114
+ remainingBlocking,
115
+ status: remainingBlocking > 0 ? "needs-clarification" : "ready",
116
+ };
117
+ }
@@ -3,13 +3,14 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  export const SURFACE_REGISTRY = [
5
5
  { id: "feed", outcome: "add-feed", label: "Feed and content surface" },
6
+ { id: "livestream", outcome: "setup-live-data", label: "Live rooms and broadcast" },
6
7
  { id: "comments", outcome: "add-comments", label: "Comments and replies" },
7
8
  { id: "chat", outcome: "add-chat", label: "Chat inbox and threads" },
8
9
  { id: "profile", outcome: "add-follow", label: "Profile and relationship graph" },
9
10
  { id: "community", outcome: "add-community", label: "Community management and identity" },
10
11
  { id: "notifications", outcome: "add-notifications", label: "Notifications and activation" },
11
12
  ];
12
- const SURFACE_SCAN_ORDER = ["feed", "comments", "chat", "community", "profile", "notifications"];
13
+ const SURFACE_SCAN_ORDER = ["feed", "livestream", "comments", "chat", "community", "profile", "notifications"];
13
14
  const outcomeBySurface = new Map(SURFACE_REGISTRY.map((s) => [s.id, s.outcome]));
14
15
  const VALID_SURFACES = new Set(SURFACE_REGISTRY.map((s) => s.id));
15
16
  function catalogRoot() {
package/dist/outcomes.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { experienceBundleFor } from "./experience.js";
1
2
  export function hasAnswer(answers, id) {
2
3
  const value = answers[id];
3
4
  return typeof value === "string" && value.trim() !== "";
@@ -28,12 +29,54 @@ const LIVE_PATTERNS = [
28
29
  EVENT_REGEX,
29
30
  LIVESTREAM_REGEX,
30
31
  ];
32
+ export const PLATFORM_FEATURE_GAPS = [
33
+ {
34
+ feature: "Scheduled events & RSVP",
35
+ match: EVENT_REGEX,
36
+ exclude: /\bevents?\s+(?:listener|listeners|handler|handlers|subscription|subscriptions|emitter|emitters|bus|stream|streams|callback|callbacks)\b|\b(?:real-?time|analytics|tracking|telemetry)\s+events?\b/i,
37
+ unavailableOn: ["flutter"],
38
+ availableOn: ["typescript", "ios", "android"],
39
+ objectIds: ["event", "rsvp"],
40
+ sdkSource: "social-plus-sdk/social/events/overview",
41
+ guidance: "The social.plus Flutter SDK does not expose scheduled-event creation, query, management, or RSVP APIs — events are available on the iOS, Android, and TypeScript SDKs. Do not invent an AmityEventRepository / event query in Dart. Choose an alternative: (a) model events as posts in a dedicated community and carry date/RSVP details in post metadata, (b) call the server-side Event REST API (api-reference/event) from your backend and surface results through your own endpoint, or (c) build the event surface on a supported platform (iOS, Android, or TypeScript/web).",
42
+ },
43
+ {
44
+ feature: "Room-based livestreaming (broadcast & room management)",
45
+ match: LIVESTREAM_REGEX,
46
+ exclude: /\blive\s+chat\b|\bvideo\s+(?:call|calling|conferenc)\w*\b/i,
47
+ unavailableOn: ["flutter"],
48
+ availableOn: ["typescript", "ios", "android"],
49
+ objectIds: ["livestream"],
50
+ sdkSource: "use-cases/social/livestream/go-live-and-room-management",
51
+ guidance: "Room-based livestreaming — broadcasting, going live, and room management (RoomRepository.createRoom / getRoom / getRooms / stopRoom) — is NOT in the social.plus Flutter SDK; it is available on the iOS, Android, and TypeScript/web SDKs. The Flutter SDK exposes only the DEPRECATED legacy Stream API (StreamRepository.getStream) for VIEWING existing stream posts — there is no Room API in Dart, so do not invent RoomRepository / createRoom. Options: (a) build the broadcast/host livestream experience on a supported platform (iOS, Android, or TypeScript/web), (b) on Flutter, render an existing legacy stream post read-only via StreamRepository.getStream(streamId) (deprecated — exposes title/status/isLive only), or (c) embed a web livestream view.",
52
+ },
53
+ ];
54
+ export function platformFeatureGapsFor(request, platform) {
55
+ return PLATFORM_FEATURE_GAPS.filter((gap) => gap.unavailableOn.includes(platform) &&
56
+ gap.match.test(request) &&
57
+ !(gap.exclude && gap.exclude.test(request)));
58
+ }
59
+ export function platformFeatureGapsForObjects(objectIds, platform) {
60
+ const present = new Set(objectIds);
61
+ const out = [];
62
+ for (const gap of PLATFORM_FEATURE_GAPS) {
63
+ if (!gap.unavailableOn.includes(platform) || !gap.objectIds)
64
+ continue;
65
+ const objects = gap.objectIds.filter((id) => present.has(id));
66
+ if (objects.length > 0)
67
+ out.push({ gap, objects });
68
+ }
69
+ return out;
70
+ }
31
71
  const FEED_PATTERNS = [
32
72
  /\b(social feature|social features|feed|timeline|post list|news feed|create post|create a post|post creation|compose post)\b/,
33
73
  ];
34
74
  const COMMENT_PATTERNS = [
35
75
  /\b(comment|comments|reply|replies|thread|discussion thread|comment composer)\b/,
36
76
  ];
77
+ const REACTION_PATTERNS = [
78
+ /\b(reaction|reactions|react|like|likes|emoji react|upvote)\b/,
79
+ ];
37
80
  const MODERATION_OUTCOME_PATTERNS = [
38
81
  /\b(moderation|report|flag|block user|mute user|hide content|abuse|review queue|admin queue)\b/,
39
82
  ];
@@ -42,25 +85,26 @@ const CHAT_PATTERNS = [
42
85
  ];
43
86
  const COMMUNITY_PATTERNS = [
44
87
  /\b(create|creating|build|building|manage|managing|join|joining|leave|leaving|set ?up)\s+(a\s+|the\s+)?communit/,
45
- /\bcommunit\w*\s+(member|membership|role|invit|categor|setting|management|directory|discovery)/,
88
+ /\bcommunit\w*\s+(member|membership|role|invit|categor|setting|management|directory|discovery|profile|page)/,
46
89
  /\bcommunity\s+(creation|management|members?|roles?|invitations?|categories|settings|moderation)\b/,
47
90
  ];
48
91
  const FOLLOW_PATTERNS = [
49
92
  /\b(follow|unfollow|follower|followers|following)\b/,
50
93
  /\b(social graph|user relationship|follow request|followers? list|following list)\b/,
94
+ /\b(?:user'?s?|member|account|my|your|their)\s+profile\b|\buser_profile_page\b/,
51
95
  /\bblocked\s+users?\b/,
52
96
  /\bmanage\s+blocked\b/,
53
97
  /\bblock\s*list\b/,
54
98
  /\bunblock\b/,
55
99
  ];
56
100
  const NOTIFICATION_PATTERNS = [
57
- /\b(notification tray|notification cent(?:er|re)|in-?app notification)/,
58
- /\bnotification (?:setting|preference)/,
101
+ /\b(notifications?\s+tray|notifications?\s+cent(?:er|re)|in-?app notifications?)/,
102
+ /\bnotifications?\s+(?:setting|preference)/,
59
103
  ];
60
104
  const TROUBLESHOOT_PATTERNS = [/\b(error|broken|crash|not working|fail|timeout|401|403)\b/];
61
105
  const VALIDATE_PATTERNS = [/\b(validate|check|correct|setup right|initiali[sz])\b/];
62
106
  const SETUP_PATTERNS = [/\b(setup|set up|install|integrate|wire|configure|init sdk|sdk setup|session lifecycle)\b|initialise?s?\b/];
63
- export const BROAD_SOCIAL_REGEX = /\b(nice|social features|social feature|engagement|community experience)\b|\b(?:feed|post|posts|comments?)\b[\s\S]{0,160}\b(?:chat|messaging|profile|followers?|following|social graph)\b|\b(?:chat|messaging|profile|followers?|following|social graph)\b[\s\S]{0,160}\b(?:feed|post|posts|comments?)\b/i;
107
+ export const BROAD_SOCIAL_REGEX = /\b(nice|social features|social feature|engagement|community experience)\b|\b(?:feeds?|post|posts|comments?)\b[\s\S]{0,160}\b(?:chat|messaging|profile|followers?|following|social graph)\b|\b(?:chat|messaging|profile|followers?|following|social graph)\b[\s\S]{0,160}\b(?:feeds?|post|posts|comments?)\b/i;
64
108
  export const DESIGN_REGEX = /\bdesign token|design tokens|theme|same design|design system|brand/i;
65
109
  export function classifyOutcome(request) {
66
110
  const normalized = request.toLowerCase();
@@ -110,10 +154,11 @@ export function classifyOutcome(request) {
110
154
  }
111
155
  export function resolveOutcome(request, answers = {}) {
112
156
  const selectedSurface = outcomeFromFeatureSurface(answers.feature_surface);
113
- if (selectedSurface && BROAD_SOCIAL_REGEX.test(request)) {
157
+ const bareOutcome = classifyOutcome(request);
158
+ if (selectedSurface && (BROAD_SOCIAL_REGEX.test(request) || experienceBundleFor(request) !== undefined || bareOutcome === "unknown")) {
114
159
  return selectedSurface;
115
160
  }
116
- return classifyOutcome(request);
161
+ return bareOutcome;
117
162
  }
118
163
  function outcomeFromFeatureSurface(answer) {
119
164
  const normalized = (answer ?? "").toLowerCase();
@@ -212,7 +257,7 @@ export function liveDataPlatformPath(platform) {
212
257
  const setupSdk = {
213
258
  id: "setup-sdk",
214
259
  patterns: SETUP_PATTERNS,
215
- interpretation: "Implement setup-sdk.",
260
+ interpretation: "Set up the social.plus SDK — client creation with the correct region/endpoint, secure session login, and access-token renewal — the foundation every other social.plus feature builds on.",
216
261
  docsQuery: (platform) => `${platform} quick start setup`,
217
262
  docs: (platform) => [
218
263
  platformQuickStart(platform),
@@ -423,8 +468,11 @@ const setupLiveData = {
423
468
  "UI handling for loading, empty, error, insertion, deletion, and modification states",
424
469
  ],
425
470
  implementationRules: () => [
471
+ "Gate the live query on a REACTIVE session-readiness signal (observe the session/auth state, seeded synchronously from its current value) so the query (re-)runs when the session becomes active AFTER the view mounts. The consuming surface usually mounts before login resolves; a one-shot readiness check at mount (onAppear / useEffect([]) / initState / onViewCreated) that returns early and never re-fires leaves the surface permanently empty even though it compiles and vise check is green. Render the not-ready and error states as retryable, not a dead end.",
426
472
  "Do not replace Live Object or Live Collection observation with polling unless the docs or user explicitly require it.",
427
473
  "Always clean up observers/subscriptions when the owning component, screen, ViewModel, or controller is destroyed.",
474
+ "On iOS/SwiftUI, deinit-based cleanup does NOT run while the owner is retained for the app's lifetime (e.g. a @StateObject view-model in a TabView): for such surfaces prefer .onDisappear / viewWillDisappear so the observer is released when the user leaves the surface, not only when the owner deallocates.",
475
+ "vise's deterministic events/stories live-collection checks verify reactivity + observer cleanup only and pass on the ABSENCE of a violation (not on positive presence) — independently confirm the surface itself is wired: events need getEvents/getRSVPs + RSVP create/update; stories need getActiveStoriesByTarget + a ring/viewer.",
428
476
  "Handle loading and error states from the live data API before rendering data as ready.",
429
477
  "For collections, use insertion/deletion/modification changes for targeted UI updates instead of assuming a full list replacement.",
430
478
  ],
@@ -441,7 +489,11 @@ const setupLiveData = {
441
489
  ],
442
490
  },
443
491
  {
444
- step: "Implement the subscription in the owning lifecycle scope and render loading, empty, error, and data states.",
492
+ step: "Drive the subscription off a reactive session-readiness signal (observe session/auth state, seeded synchronously from the current value) so the query (re-)runs when the session becomes active — the surface usually mounts before login resolves, and a one-shot readiness check at mount that never re-fires leaves it permanently empty.",
493
+ evidence: ["social-plus-sdk/getting-started/authentication", liveDataPlatformPath(ctx.platform)],
494
+ },
495
+ {
496
+ step: "Implement the subscription in the owning lifecycle scope and render loading, empty, error, and data states (with not-ready and error states rendered as retryable, not a dead end).",
445
497
  evidence: ["requiredInputs.lifecycle owner for subscribe/unsubscribe cleanup", "implementationRules.loading-error-states"],
446
498
  },
447
499
  {
@@ -489,8 +541,8 @@ const addFeed = {
489
541
  docsQuery: (platform) => `${platform} social feed posts`,
490
542
  docs: (platform) => [
491
543
  {
492
- path: "social-plus-sdk/social/posts",
493
- reason: "Canonical social post/feed concepts and API entrypoint.",
544
+ path: "social-plus-sdk/social/content-management/posts/creation/text-post",
545
+ reason: "Canonical post model and creation API entrypoint; pair with the live-objects/collections docs below for feed querying.",
494
546
  },
495
547
  {
496
548
  path: "social-plus-sdk/core-concepts/realtime-communication/live-objects-collections/overview",
@@ -520,7 +572,7 @@ const addFeed = {
520
572
  questions.push({
521
573
  id: "feed_scope",
522
574
  question: "What scope should the first feed use?",
523
- why: "Feed APIs and query shape depend on whether the content is global, user-specific, or community-specific.",
575
+ why: "Feed APIs and query shape depend on whether the content is global, user-specific, or community-specific. A general social feed defaults to global (app-wide); choose community only when the request is explicitly community-scoped — and note a community feed additionally requires the user to be a member before they can post.",
524
576
  required: true,
525
577
  blocksImplementationWhenMissing: true,
526
578
  options: ["global", "user", "community"],
@@ -528,10 +580,10 @@ const addFeed = {
528
580
  questions.push({
529
581
  id: "feed_target",
530
582
  question: "What concrete feed target should reads and post creation use?",
531
- why: "A coding agent must not invent community IDs, user timeline targets, or global feed assumptions. If this is a community feed, provide the existing communityId or the flow that lets users select/create one.",
583
+ why: "Prefer the global (app-wide) feed or the current user's feed for a general social feed both are ID-free, need no membership to post, and are the safe default. The no-invent rule applies to IDs: never invent a communityId, targetId, or feedId. Choose a community target only when the request names a community-scoped surface, and then source it from route/app state, SDK discovery, user selection, or a create flow.",
532
584
  required: true,
533
585
  blocksImplementationWhenMissing: true,
534
- options: ["existing communityId", "current user's feed", "selected user's feed", "app-defined/global feed", "ask user at runtime"],
586
+ options: ["app-defined/global feed", "current user's feed", "selected user's feed", "community selected from route/discovery/create flow", "ask user at runtime"],
535
587
  });
536
588
  questions.push({
537
589
  id: "target_screen_or_route",
@@ -564,6 +616,14 @@ const addFeed = {
564
616
  blocksImplementationWhenMissing: false,
565
617
  options: ["all SDK-available creation types", "text only", "text + media", "text + media + poll", "custom subset"],
566
618
  });
619
+ questions.push({
620
+ id: "auth_secure_mode",
621
+ question: "Is this integration production-bound (secure-mode auth with a server-issued token) or dev/testing only (unsecure auth, no token)?",
622
+ why: "Unsecure (development) auth — login without a server-issued auth token — lets any client impersonate any user. Fine for local testing, but production MUST use secure mode (a server-issued auth token + a renewal handler). `vise check` surfaces a security advisory whenever the code uses unsecure auth, regardless of this answer.",
623
+ required: false,
624
+ blocksImplementationWhenMissing: false,
625
+ options: ["production — secure mode (server-issued auth token)", "dev/testing only — unsecure auth (no token)"],
626
+ });
567
627
  return filterAnswered(ctx.answers, questions);
568
628
  },
569
629
  requiredInputs: () => [
@@ -571,7 +631,7 @@ const addFeed = {
571
631
  "lifecycle owner for subscribe/unsubscribe cleanup",
572
632
  "feature choice: feed, comments, profile, community, chat, notifications, or another social surface",
573
633
  "feed scope: global, user, or community",
574
- "concrete feed target for reads and post creation: existing communityId, selected user target, current user target, or app-defined feed target",
634
+ "concrete feed target for reads and post creation: app-defined/global feed, current user feed, selected user target, or community selected from route/discovery/create flow",
575
635
  "target screen or route for the feed UI",
576
636
  ],
577
637
  implementationRules: () => [
@@ -601,10 +661,17 @@ const addFeed = {
601
661
  {
602
662
  step: "Fetch the canonical social/feed docs and use platform-appropriate live collection patterns.",
603
663
  evidence: [
604
- "social-plus-sdk/social/posts",
664
+ "social-plus-sdk/social/content-management/posts/creation/text-post",
605
665
  "social-plus-sdk/core-concepts/realtime-communication/live-objects-collections/overview",
606
666
  ],
607
667
  },
668
+ {
669
+ step: "Drive the feed query off a reactive session-readiness signal (observe session/auth state, seeded synchronously) so it (re-)runs when the session becomes active — the feed screen usually mounts before login resolves, and a one-shot readiness check at mount (onAppear / useEffect([]) / initState) that never re-fires leaves the feed permanently empty even though it compiles and vise check is green. Render the not-ready and error states as retryable, not a dead end.",
670
+ evidence: [
671
+ "social-plus-sdk/getting-started/authentication",
672
+ "requiredInputs.target screen or route",
673
+ ],
674
+ },
608
675
  {
609
676
  step: "Wire feed pagination: after the initial page loads, expose a 'Load more' action (button, scroll trigger, or infinite-query) that calls the collection's next-page method (loadMore() / nextPage() / onNextPage()). Check hasMore / hasNextPage before showing the control so it disappears when the list is exhausted. Use only opaque cursor tokens returned by the SDK — never construct numeric page offsets. On React/TypeScript, useAmityElement with an infinite-query hook is the idiomatic pattern; on Flutter, a ScrollController + loadMore callback; on Android, PagingData with a LazyColumn scroll trigger.",
610
677
  evidence: [
@@ -632,12 +699,16 @@ const addFeed = {
632
699
  { step: "Reuse the host app's existing visual system for the social surface.", evidence: designEvidence },
633
700
  { step: "Implement loading, empty, error, and data states.", evidence: ["implementationRules.file-specific edits"] },
634
701
  {
635
- step: "In post card renderers, resolve each media type from the parent post OR its childrenPosts — in a feed the parent is usually dataType 'text' and the image/video/poll/clip/room rides on a child post. Handle at minimum: text, image, video, file, poll, clip, room. Do not render placeholder labels like '[Image post]' or '[Poll post]'; read the SDK data/accessors and render the actual content. Do NOT gate media on the parent's dataType alone (e.g. post.dataType === 'poll') — that never matches a text parent and the content silently never renders. When rendering the text body itself (post or comment), apply @mention highlights if metadata carries mention entries ({ type: 'user', index, length, userId }, length excluding the '@'): wrap each [index, index + length + 1] span in a styled element and resolve userId to a display name, rather than printing raw text. Pass mentionees on create so mentioned users are notified.",
636
- evidence: ["social-plus-sdk/social/posts", "intake.feed_post_type_scope", "capabilityAvailability.available"],
702
+ step: "In post card renderers, render the normal post body/description/caption when it is present. Do not make `postId` the primary visible content, and do not invent a required title model: `post.title`, `metadata.title`, or custom-payload title fields are optional product choices, not requirements for a correct social.plus post renderer.",
703
+ evidence: ["social-plus-sdk/social/content-management/posts/creation/text-post", "capabilityAvailability.available"],
704
+ },
705
+ {
706
+ step: "In post card renderers, resolve each media type from the parent post OR its childrenPosts — in a feed the parent is usually dataType 'text' and the image/video/poll/clip/room rides on a child post. Handle at minimum: text, image, video, file, poll, clip, room. Do not render placeholder labels like '[Image post]' or '[Poll post]'; read the SDK data/accessors and render the actual content. Do NOT gate media on the parent's dataType alone (e.g. post.dataType === 'poll') — that never matches a text parent and the content silently never renders. Read child media from the RESOLVED children accessor (post.childrenPosts, whose entries are Post objects exposing .dataType / getImageInfo() etc.) — or, if you only have the raw `post.children` array, resolve the child-post IDs first (getPost(childId) / getPostByIds). Do NOT cast `post.children` to post objects and read `.dataType` on it: `post.children` holds child-post ID STRINGS, so `.dataType` is undefined there and attachments silently never render — a resolution bug a presence check (the code merely references children) cannot catch. When rendering the text body itself (post or comment), apply @mention highlights if metadata carries mention entries ({ type: 'user', index, length, userId }, length excluding the '@'): wrap each [index, index + length + 1] span in a styled element and resolve userId to a display name, rather than printing raw text. Pass mentionees on create so mentioned users are notified.",
707
+ evidence: ["social-plus-sdk/social/content-management/posts/creation/text-post", "intake.feed_post_type_scope", "capabilityAvailability.available"],
637
708
  },
638
709
  {
639
710
  step: "Read SDK objects through their own typed accessors and types (e.g. post.getImageInfo(), the Amity.* types) instead of casting return values to hand-written shapes like (post.data as { text?: string }). A hand-written cast silences the type-checker, so when an SDK upgrade renames a field your build still compiles and the bug only surfaces at runtime — reading through SDK types lets tsc/analyze flag the breakage.",
640
- evidence: ["social-plus-sdk/social/posts"],
711
+ evidence: ["social-plus-sdk/social/content-management/posts/creation/text-post"],
641
712
  },
642
713
  {
643
714
  step: "For community and user avatars, use the SDK-provided URL (community.avatar?.fileUrl / community.avatarImage?.getUrl / community.getAvatar()?.getUrl) and fall back to an initial only when the field is absent. Do not derive an initial from the display name as the sole identifier.",
@@ -657,6 +728,15 @@ const addFeed = {
657
728
  evidence: ["social-plus-sdk/social/content-management/comments/creation/text-comment"],
658
729
  },
659
730
  ] : []),
731
+ ...(REACTION_PATTERNS.some((p) => p.test(ctx.request.toLowerCase())) ? [
732
+ {
733
+ step: "If post cards display a reaction/like control, wire it end to end via ReactionRepository — not a static icon. Add on tap with addReaction(referenceType: 'post', referenceId: post.postId, reactionName) and remove with removeReaction(...) on un-tap (reaction names are case-sensitive and app-defined, e.g. 'like'). Source the count and the current user's state from the post's own reaction data (post.reactionsCount / post.myReactions) so the UI reflects optimistic toggles, and use ReactionRepository.getReactions({ referenceId, referenceType: 'post' }) only when you need the per-user breakdown. A heart/like button with no add/remove wiring (or a hardcoded count) is incomplete — users expect the count to update when they react.",
734
+ evidence: [
735
+ "social-plus-sdk/core-concepts/content-handling/reactions.mdx",
736
+ "social-plus-sdk/core-concepts/realtime-communication/live-objects-collections/overview",
737
+ ],
738
+ },
739
+ ] : []),
660
740
  ...(/notification|bell|tray|inbox/i.test(ctx.request) ? [
661
741
  {
662
742
  step: "If the design includes a notification bell or notification center, implement it with the notificationTray SDK: getNotificationTrayItems (live collection), markItemsSeen on open, and an unread badge sourced from getNotificationTraySeen.",
@@ -671,12 +751,12 @@ const addFeed = {
671
751
  step: "If the post composer supports poll creation, implement the two-step creation chain: (1) create the poll with the platform SDK poll repository — returns a Poll/pollId; (2) link that poll into a post with the platform post builder, for example `PostRepository.createPost({ targetType, targetId, data: { text: '', pollId } })` on TypeScript/React Native or Android's dedicated `createPollPost(targetType, targetId, pollId, text, ...)` API. Rendering poll answers (votePoll/unvotePoll) is the read-side; without both creation steps the poll composer silently does nothing. Offer a dedicated poll-builder UI (question input + dynamic answer list) so users can author polls inline.",
672
752
  evidence: [
673
753
  "social-plus-sdk/social/content-management/posts/creation/poll-post",
674
- "social-plus-sdk/social/posts",
754
+ "social-plus-sdk/social/content-management/posts/creation/text-post",
675
755
  ],
676
756
  },
677
757
  {
678
758
  step: "In post card headers, show the post's target context when post.targetType === 'community': display 'Author.displayName › Community.displayName' by subscribing to CommunityRepository.getCommunity(post.targetId, cb) for the live community name.",
679
- evidence: ["social-plus-sdk/social/communities", "social-plus-sdk/social/posts"],
759
+ evidence: ["social-plus-sdk/social/communities", "social-plus-sdk/social/content-management/posts/creation/text-post"],
680
760
  },
681
761
  {
682
762
  step: "For room-type posts, subscribe to RoomRepository.getRoom(roomId, cb) to get the Room object and display room.title (not room.roomId). Show room.status (live/idle/ended) as a badge.",
@@ -684,7 +764,7 @@ const addFeed = {
684
764
  },
685
765
  {
686
766
  step: "Give each post card an actions menu gated by the viewer's relationship to the post. If the viewer is the author (post.postedUserId === currentUserId), show edit (PostRepository.editPost) and delete (PostRepository.deletePost). For posts the viewer does not own, show report/flag (PostRepository.flagPost). Author-ownership actions and moderator-role actions are distinct — do not show edit/delete to non-authors.",
687
- evidence: ["social-plus-sdk/social/posts", "social-plus-sdk/social/moderation"],
767
+ evidence: ["social-plus-sdk/social/content-management/posts/creation/text-post", "social-plus-sdk/social/moderation"],
688
768
  },
689
769
  { step: "Run validate_setup and detected command sensors after edits.", evidence: ["validate_setup", "run_sensors"] },
690
770
  ];
@@ -699,6 +779,7 @@ const addFeed = {
699
779
  "posts.status-filtered",
700
780
  "pagination.cursor-opaque",
701
781
  "unread.server-synced",
782
+ "feed.post-body-rendering",
702
783
  "feed.rich-post-rendering",
703
784
  "feed.rich-post-composer-scope",
704
785
  "feed.post-type-scope-explicit",
@@ -710,8 +791,8 @@ const addFeed = {
710
791
  "profile.social-counts",
711
792
  ],
712
793
  stopConditions: (ctx) => filterStops(ctx.answers, [
713
- { id: "feed_target", text: "The concrete feed target is unknown; do not invent communityId, targetId, feedId, or global feed assumptions." },
714
- { id: "feed_target", text: "If post creation is in scope, the write target must be provided by the user, existing app state, or a customer-owned selection/create flow." },
794
+ { id: "feed_target", text: "The concrete feed target is unknown; never invent a communityId, targetId, or feedId. For a general social feed, default to the ID-free app-wide global feed or the current user's feed; reserve a specific community/target for an explicitly community-scoped request." },
795
+ { id: "feed_target", text: "If post creation is in scope, the write target must be a concrete feed — the global or current-user feed (no id, no membership needed), or a community/selected target the user or app state provides (never a hardcoded or invented id)." },
715
796
  ]),
716
797
  resolvePlan: () => [
717
798
  {
@@ -891,7 +972,7 @@ const addModeration = {
891
972
  const questions = [
892
973
  {
893
974
  id: "moderation_target",
894
- question: "What content types need moderation (post, comment, message, user)?",
975
+ question: "What content types need moderation (posts, comments, messages, users, or all)?",
895
976
  why: "Each content type has different moderation APIs and UI patterns.",
896
977
  required: true,
897
978
  blocksImplementationWhenMissing: true,
@@ -922,7 +1003,7 @@ const addModeration = {
922
1003
  return filterAnswered(ctx.answers, questions);
923
1004
  },
924
1005
  requiredInputs: () => [
925
- "moderation target types: post, comment, message, user",
1006
+ "moderation target types: posts, comments, messages, users, or all",
926
1007
  "moderation action set: report, block, mute, hide, admin queue",
927
1008
  "post-action rendering policy for blocked/hidden content",
928
1009
  ],
@@ -1008,9 +1089,16 @@ const addChat = {
1008
1089
  {
1009
1090
  id: "channel_source",
1010
1091
  question: "How are channels/conversations created or discovered?",
1011
- why: "Avoids invented channel IDs. Channels come from user selection, SDK query, or app logic.",
1092
+ why: "For a chat inbox/browse surface, prefer the current user's own channels — queryChannels()/getChannels with membership:'member' is ID-free and needs no channelId. Creating a 1:1 DM needs a recipient userId and a group needs member userIds (createChannel); opening a specific existing channel uses its channelId from the inbox query. The no-invent rule applies to IDs: never invent a channelId or member list.",
1012
1093
  required: true,
1013
1094
  blocksImplementationWhenMissing: true,
1095
+ options: [
1096
+ "my inbox (queryChannels()/getChannels, membership:'member')",
1097
+ "user/app selection of an existing channel",
1098
+ "create 1:1 DM (needs recipient userId)",
1099
+ "create group channel (needs member userIds)",
1100
+ "ask user at runtime",
1101
+ ],
1014
1102
  },
1015
1103
  {
1016
1104
  id: "message_capabilities",
@@ -1080,8 +1168,12 @@ const addChat = {
1080
1168
  step: "Implement channel query/creation and message observation with lifecycle cleanup.",
1081
1169
  evidence: ["social-plus-sdk/chat/channels", "social-plus-sdk/chat/messages"],
1082
1170
  },
1171
+ {
1172
+ step: "Drive the channel-list (and message) query off a reactive session-readiness signal (observe session/auth state, seeded synchronously) so it (re-)runs when the session becomes active — the inbox/thread usually mounts before login resolves, and a one-shot readiness check at mount (onAppear / useEffect([]) / initState) that never re-fires leaves chat permanently empty even though it compiles and vise check is green. Render the not-ready and error states as retryable, not a dead end.",
1173
+ evidence: ["social-plus-sdk/getting-started/authentication", "social-plus-sdk/chat/channels"],
1174
+ },
1083
1175
  { step: "Reuse the host app's existing visual system for chat UI.", evidence: designEvidence },
1084
- { step: "Add message send with error handling and auth gate. Observe each message's syncState and surface failed sends (error state) with a retry/delete affordance instead of rendering optimistic success unconditionally; clean up unrecoverable failures (e.g. deleteFailedMessages) on init.", evidence: ["implementationRules.file-specific edits"] },
1176
+ { step: "Add message send with error handling and auth gate. Observe each message's syncState and surface failed sends (error state) with a retry/delete affordance instead of rendering optimistic success unconditionally; clean up unrecoverable failures (e.g. deleteFailedMessages) on init. For a community channel, sending may require joining first (joinChannel) — surface a join affordance so a non-member's send does not silently fail.", evidence: ["implementationRules.file-specific edits"] },
1085
1177
  { step: "Render the chat inbox from SDK channel queries/live collections. Show unread counts/badges from the SDK on channel rows or the chat tab (`channel.getUnreadCount()`, `getSubChannelsUnreadCount()`, or `AmityCoreClient.observeUserUnread()` / `getTotalChannelUnread()`), and declare channel-list sorting explicitly (typically last activity descending) so recency cannot be reversed by UI defaults.", evidence: ["social-plus-sdk/chat/channels", "intake.chat_inbox_scope", "capabilityAvailability.available"] },
1086
1178
  { step: "Declare message order explicitly with `AmityMessageQuerySortOption.FIRST_CREATED` or `LAST_CREATED` so the message thread cannot invert by relying on defaults.", evidence: ["social-plus-sdk/chat/messages"] },
1087
1179
  { step: "Wire read receipts and typing indicators if required — and mark the channel/messages read on open (channel.markAsRead() / message.markRead()) so the server-side unread count actually decrements. Reading the unread count without ever marking read leaves the badge stuck. Note: the MarkerSyncEngine receipt-sync API (startMessageReceiptSync/stopMessageReceiptSync) is deprecated and being sun-set — track read state with the unread count + markRead() instead.", evidence: ["requiredInputs.read receipt and typing indicator requirements"] },
@@ -1163,10 +1255,17 @@ const addCommunity = {
1163
1255
  },
1164
1256
  {
1165
1257
  id: "community_target",
1166
- question: "Where does the community ID come from (route param, selection, or a create flow)?",
1167
- why: "The coding agent must not invent a communityId; it needs a concrete source.",
1258
+ question: "Where do the communities come from a discovery query, a create flow, user selection, or an app route/detail state?",
1259
+ why: "For a browse/join or create surface this is ID-free: discover communities via getCommunities/searchCommunities/getRecommendedCommunities/getTrendingCommunities (a query, no communityId), or create one with createCommunity (the id is server-generated). A concrete inbound community object/id belongs only to a detail/members or membership-management surface of a specific existing community — source it from route/app state, user selection, discovery, or the create-flow return value. The no-invent rule applies to IDs: never invent a communityId.",
1168
1260
  required: true,
1169
1261
  blocksImplementationWhenMissing: true,
1262
+ options: [
1263
+ "discovery query (getCommunities/searchCommunities/getRecommendedCommunities/getTrendingCommunities)",
1264
+ "createCommunity flow (server-generated id)",
1265
+ "user selection of an existing community",
1266
+ "route/app state for an existing community",
1267
+ "ask user at runtime",
1268
+ ],
1170
1269
  },
1171
1270
  {
1172
1271
  id: "lifecycle_owner",
@@ -1188,7 +1287,7 @@ const addCommunity = {
1188
1287
  requiredInputs: () => [
1189
1288
  "community surface: create, browse/join, detail+members, or membership management",
1190
1289
  "community privacy model: public (instant) vs private (join request)",
1191
- "concrete community ID source (route param, selection, or create flow)",
1290
+ "natural community source (route/app state, discovery, user selection, or create flow)",
1192
1291
  "lifecycle owner for community/member observer cleanup",
1193
1292
  "target screen or component",
1194
1293
  ],
@@ -1205,8 +1304,8 @@ const addCommunity = {
1205
1304
  : ["requiredInputs.design token or theme source file"];
1206
1305
  return [
1207
1306
  {
1208
- step: "Confirm the community surface, privacy model, community ID source, and target screen before writing code.",
1209
- evidence: ["requiredInputs.community surface", "requiredInputs.community privacy model", "requiredInputs.concrete community ID source"],
1307
+ step: "Confirm the community surface, privacy model, natural community source, and target screen before writing code.",
1308
+ evidence: ["requiredInputs.community surface", "requiredInputs.community privacy model", "requiredInputs.natural community source"],
1210
1309
  },
1211
1310
  {
1212
1311
  step: "Implement the community/member query with the platform's Live Collection (AmityCommunityRepository / getMembers), with cleanup on the lifecycle owner.",
@@ -1229,13 +1328,11 @@ const addCommunity = {
1229
1328
  "community.avatar-from-sdk",
1230
1329
  "community.display-name-from-sdk",
1231
1330
  "moderation.role-gated-action",
1232
- "validate_setup",
1233
- "run_sensors",
1234
1331
  ],
1235
1332
  stopConditions: (ctx) => filterStops(ctx.answers, [
1236
1333
  { id: "community_scope", text: "The community surface is unknown; do not assume create vs browse vs members vs management." },
1237
1334
  { id: "community_privacy", text: "The privacy model is unknown; public (instant) and private (join-request) flows differ and must not be guessed." },
1238
- { id: "community_target", text: "The community ID source is unknown; do not invent a communityId." },
1335
+ { id: "community_target", text: "The natural community source is unknown; do not invent a communityId." },
1239
1336
  { id: "lifecycle_owner", text: "The lifecycle owner for community/member observer cleanup is unknown." },
1240
1337
  ]),
1241
1338
  resolvePlan: () => [
@@ -1290,10 +1387,17 @@ const addFollow = {
1290
1387
  },
1291
1388
  {
1292
1389
  id: "follow_target",
1293
- question: "Where does the target userId come from (route param, profile selection, current user)?",
1294
- why: "The coding agent must not invent a userId; it needs a concrete source.",
1390
+ question: "Whose social graph is this — the current user's own (follower/following lists, counts), another user's profile, or a follow/unfollow button (always another user)?",
1391
+ why: "Scope this to the surface. For the current user's OWN follower/following lists, counts, and relationship state, the ID-free default is getCurrentUser()/client.userId — pass that own id to getFollowInfo (counts) and getFollowers/getFollowings (lists); the SDK routes those to the ID-free /me endpoint when the id is your own, so no external userId is needed. A follow/unfollow BUTTON always targets ANOTHER user, so a concrete target userId is required there (do not default it to self); viewing another user's profile/lists likewise needs that user's id. The no-invent rule applies to IDs: never invent a userId.",
1295
1392
  required: true,
1296
1393
  blocksImplementationWhenMissing: true,
1394
+ options: [
1395
+ "current user, own graph (getCurrentUser()/client.userId — for own lists/counts)",
1396
+ "another user's profile (their userId — for viewing their lists/counts)",
1397
+ "follow/unfollow button target (another user's userId — required, never self)",
1398
+ "route param userId",
1399
+ "ask user at runtime",
1400
+ ],
1297
1401
  },
1298
1402
  {
1299
1403
  id: "lifecycle_owner",
@@ -1359,8 +1463,6 @@ const addFollow = {
1359
1463
  "profile.identity-from-sdk",
1360
1464
  "profile.avatar-from-sdk",
1361
1465
  "profile.social-counts",
1362
- "validate_setup",
1363
- "run_sensors",
1364
1466
  ],
1365
1467
  stopConditions: (ctx) => filterStops(ctx.answers, [
1366
1468
  { id: "follow_surface", text: "The social-graph surface is unknown; do not assume follow button vs lists vs blocked users." },
@@ -1455,9 +1557,6 @@ const addNotifications = {
1455
1557
  "notifications.tray-live",
1456
1558
  "notifications.mark-seen",
1457
1559
  "notifications.preferences-respected",
1458
- "this is in-app tray, not push setup",
1459
- "validate_setup",
1460
- "run_sensors",
1461
1560
  ],
1462
1561
  stopConditions: (ctx) => filterStops(ctx.answers, [
1463
1562
  { id: "notification_surface", text: "The notification surface is unknown; do not assume tray vs badge vs settings." },
@@ -1,11 +1,13 @@
1
1
  export const PRODUCT_EXPECTATION_TITLES = {
2
2
  "feed.target-resolved": "Feed target comes from app state",
3
+ "feed.source-used": "Feed source uses SDK post APIs",
3
4
  "feed.ui-states-present": "Feed renders loading, empty, and error states",
4
5
  "feed.pagination-wired": "Feed pagination is wired",
5
6
  "moderation.affordance-present": "UGC moderation affordance is present",
6
7
  "posts.status-filtered": "Post queries filter unsafe statuses",
7
8
  "pagination.cursor-opaque": "Pagination cursors stay opaque",
8
9
  "unread.server-synced": "Unread counts use the server-synced stream",
10
+ "feed.post-body-rendering": "Feed renders post body content",
9
11
  "feed.rich-post-rendering": "Feed renders rich post types",
10
12
  "feed.rich-post-composer-scope": "Feed composer surfaces rich post scope",
11
13
  "feed.post-type-scope-explicit": "Feed post-type scope is explicit",
@@ -45,6 +47,12 @@ const platformBindings = (expectationId, sensorsByPlatform) => Object.entries(se
45
47
  platform,
46
48
  })));
47
49
  export const PRODUCT_EXPECTATION_BINDINGS = [
50
+ ...platformBindings("feed.source-used", {
51
+ typescript: "typescript.feed.source-used",
52
+ "react-native": "react-native.feed.source-used",
53
+ flutter: "flutter.feed.source-used",
54
+ ios: "ios.feed.source-used",
55
+ }),
48
56
  ...platformBindings("feed.target-resolved", {
49
57
  typescript: ["typescript.feed.target.literal", "typescript.feed.target-type-explicit"],
50
58
  "react-native": ["react-native.feed.target.literal", "react-native.feed.target-type-explicit"],
@@ -114,6 +122,13 @@ export const PRODUCT_EXPECTATION_BINDINGS = [
114
122
  flutter: "flutter.unread.subscribed-not-counted",
115
123
  ios: "ios.unread.subscribed-not-counted",
116
124
  }),
125
+ ...platformBindings("feed.post-body-rendering", {
126
+ typescript: "typescript.feed.post-body-rendered",
127
+ "react-native": "react-native.feed.post-body-rendered",
128
+ android: "android.feed.post-body-rendered",
129
+ flutter: "flutter.feed.post-body-rendered",
130
+ ios: "ios.feed.post-body-rendered",
131
+ }),
117
132
  {
118
133
  expectationId: "feed.rich-post-rendering",
119
134
  sensorId: "typescript.feed.post-datatype-handled",