@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.
- package/CHANGELOG.md +75 -2
- package/README.md +23 -10
- package/dist/capabilities.js +35 -4
- package/dist/entryState.js +71 -0
- package/dist/experience.js +70 -0
- package/dist/explore.js +51 -0
- package/dist/flow.js +382 -0
- package/dist/humanFormat.js +251 -0
- package/dist/intake.js +117 -0
- package/dist/intelligence/placement.js +2 -1
- package/dist/outcomes.js +141 -42
- package/dist/productExpectations.js +15 -0
- package/dist/requestReadiness.js +99 -0
- package/dist/server.js +675 -56
- package/dist/sidecar.js +5 -0
- package/dist/solutionPath.js +2 -2
- package/dist/tools/compliance.js +1014 -133
- package/dist/tools/creative.js +14 -12
- package/dist/tools/debug.js +83 -26
- package/dist/tools/design.js +344 -14
- package/dist/tools/experienceCompiler.js +1 -1
- package/dist/tools/experienceSensors.js +1 -1
- package/dist/tools/harness.js +13 -0
- package/dist/tools/integration.js +263 -90
- package/dist/tools/learning.js +1 -3
- package/dist/tools/project.js +987 -72
- package/dist/tools/sdkFacts.js +8 -1
- package/dist/tools/smoke.js +134 -0
- package/dist/tools/uxHarness.js +54 -6
- package/dist/uikitCustomization.js +22 -6
- package/dist/version.js +7 -3
- package/package.json +1 -1
- package/packages/intelligence/catalog/catalog.schema.json +1 -1
- package/packages/intelligence/catalog/experience-objects.json +2 -2
- package/packages/intelligence/catalog/variants.json +24 -0
- package/rules/event.yaml +3 -0
- package/rules/feed.yaml +251 -0
- package/rules/invitation.yaml +4 -0
- package/rules/live-data.yaml +110 -0
- package/rules/notification-tray.yaml +4 -0
- package/rules/poll.yaml +5 -0
- package/rules/sdk-lifecycle.yaml +559 -0
- package/rules/search.yaml +5 -0
- package/rules/security.yaml +12 -0
- package/rules/story.yaml +5 -0
- package/rules/user-blocking.yaml +5 -0
- 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(
|
|
58
|
-
/\
|
|
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(?:
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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: "
|
|
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
|
|
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: "
|
|
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: ["
|
|
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:
|
|
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,
|
|
636
|
-
evidence: ["social-plus-sdk/social/posts", "
|
|
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;
|
|
714
|
-
{ id: "feed_target", text: "If post creation is in scope, the write target must be
|
|
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 (
|
|
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:
|
|
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: "
|
|
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
|
|
1167
|
-
why: "
|
|
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
|
-
"
|
|
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
|
|
1209
|
-
evidence: ["requiredInputs.community surface", "requiredInputs.community privacy model", "requiredInputs.
|
|
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
|
|
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: "
|
|
1294
|
-
why: "
|
|
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",
|