Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion apps/chat-server/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,19 @@ export interface ChatConversation {

/** A file / call-recording / card chip parsed from a message. Generic lift of `TeamsAttachment`. */
export interface ChatAttachment {
kind: "file" | "recording" | "card"
kind: "file" | "image" | "recording" | "card"
name?: string
type?: string
/** Opens in a new tab (provider SSO). */
url?: string
/** Already media-proxied when the source is provider-hosted (e.g. Teams AMS). */
thumbnailUrl?: string
title?: string
/** Card body text, when the provider payload carried more than a title (t181). */
text?: string
/** Intrinsic pixel size of a `kind: "image"` preview — reserves the box before the bytes load. */
width?: number
height?: number
}

/** One reaction bucket on a message. Generic lift of `TeamsReaction`. */
Expand Down
120 changes: 111 additions & 9 deletions apps/chat-server/src/providers/mock-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
ProviderSearchHit,
ProviderSearchPage,
UploadImage,
UploadOpts,
UploadResult,
} from "./provider.ts"
import { ProviderError } from "./provider.ts"
Expand Down Expand Up @@ -215,9 +216,9 @@ function richSeed(): Fixture[] {
topic: "Design review",
title: "Design review",
memberIds: ["other-oid", "third-oid"],
lastMessageId: "6008",
lastMessageTs: ago(3),
lastMessagePreview: "and one more thing",
lastMessageId: "6017",
lastMessageTs: ago(1),
lastMessagePreview: "Plain link, stays external: the docs",
readTs: ago(30),
}),
messages: [
Expand Down Expand Up @@ -294,6 +295,101 @@ function richSeed(): Fixture[] {
senderId: OTHER,
senderName: "Other Person",
}),
// t181 — the five message classes that used to render empty or lossy. Bodies are the real
// renderer output for the corresponding probe payloads, so the mock stack shows
// exactly what a live thread now shows.
msg({
id: "6009",
ts: ago(2),
body: "<p>the pronunciation like this, any feedbacks guys?</p>",
senderId: OTHER,
senderName: "Other Person",
attachments: [
{
kind: "image",
name: "image (6).png",
type: "png",
url: "https://example.sharepoint.com/:i:/g/personal/x/IQAC",
thumbnailUrl:
"/api/chat/media?service=teams&url=https%3A%2F%2Fas-api.asm.skype.com%2Fv1%2Fobjects%2F0-ea-d12-mock%2Fviews%2Fimgo",
width: 96,
height: 96,
},
],
}),
msg({
id: "6010",
ts: ago(2),
body: "<p>Could i ask if we can fix it in local development env?</p>",
senderId: THIRD,
senderName: "Third Person",
attachments: [
{
kind: "card",
title: "Loop component",
url: "https://example.sharepoint.com/:fl:/g/personal/x/IQDcSTYg",
},
],
}),
msg({
id: "6011",
ts: ago(2),
body: '<p>Everyone—that\u2019s a wrap. Here\u2019s the rundown. <a href="https://example.sharepoint.com/:fl:/g/personal/x/IQC-7ItY" itemtype="http://schema.skype.com/FluidAutoEmbedLink">Loop page</a></p>',
senderId: THIRD,
senderName: "Third Person",
}),
msg({
id: "6012",
ts: ago(1),
body: '<div><video src="/api/chat/media?service=teams&url=https%3A%2F%2Fas-prod.asyncgw.teams.microsoft.com%2Fv1%2Fobjects%2F0-ea-d10-mock%2Fviews%2Fvideo" itemtype="http://schema.skype.com/AMSVideo" data-duration="PT18S" width="540" height="960"></video></div>',
senderId: OTHER,
senderName: "Other Person",
}),
msg({
id: "6013",
ts: ago(1),
body: "",
senderId: THIRD,
senderName: "Polly",
attachments: [
{
kind: "card",
title: "🔓 Non-Anonymous",
text: "Subjectively, how do you feel about your velocity this week versus last",
},
],
}),
msg({
id: "6014",
ts: ago(1),
body: 'Morning team.. can confirm if this link is valid?\r\n<blockquote class="forward" itemtype="http://schema.skype.com/Forward"><span class="forward-label">Forwarded</span><div>This is the link for Android, copied from eIRIS</div></blockquote>',
senderId: OTHER,
senderName: "Other Person",
}),
// t183 fixtures: message links the app should resolve in place. 6015 targets a message in
// THIS conversation (jump without a pane switch); 6016 targets another conversation (pane
// switch, then jump); 6017 is an ordinary external link that must stay external.
msg({
id: "6015",
ts: ago(1),
body: 'Context is up here: <a href="https://teams.microsoft.com/l/message/19:rich@thread.v2/6002?context=%7B%22contextType%22%3A%22chat%22%7D">the earlier message</a>',
senderId: THIRD,
senderName: "Third Person",
}),
msg({
id: "6016",
ts: ago(1),
body: 'Cross-thread: <a href="https://teams.microsoft.com/l/message/19:group@thread.v2/3001?context=%7B%22contextType%22%3A%22chat%22%7D">that message in Project X</a>',
senderId: OTHER,
senderName: "Other Person",
}),
msg({
id: "6017",
ts: ago(1),
body: 'Plain link, stays external: <a href="https://example.com/docs">the docs</a>',
senderId: OTHER,
senderName: "Other Person",
}),
],
},
{
Expand Down Expand Up @@ -524,22 +620,28 @@ export class MockProvider implements ChatProvider {
]
}

async uploadImage(convId: string, _image: UploadImage, text?: string): Promise<UploadResult> {
const r = await this.sendReply(convId, text || "[image]")
// The mock forwards quotes/mentions to sendReply so the mock stack exercises the same wiring the
// Teams path uses (t182) — an attachment send must not lose them.
async uploadImage(convId: string, _image: UploadImage, opts?: UploadOpts): Promise<UploadResult> {
const r = await this.sendReply(convId, opts?.text || "[image]", opts)
return { ok: true, msgId: r.ts }
}

async uploadImages(convId: string, _images: UploadImage[], text?: string): Promise<UploadResult> {
const r = await this.sendReply(convId, text || "[images]")
async uploadImages(
convId: string,
_images: UploadImage[],
opts?: UploadOpts,
): Promise<UploadResult> {
const r = await this.sendReply(convId, opts?.text || "[images]", opts)
return { ok: true, msgId: r.ts }
}

async uploadFile(
convId: string,
file: { filename: string; base64: string; contentType?: string },
text?: string,
opts?: UploadOpts,
): Promise<UploadResult> {
const r = await this.sendReply(convId, text || `[file] ${file.filename}`)
const r = await this.sendReply(convId, opts?.text || `[file] ${file.filename}`, opts)
return { ok: true, msgId: r.ts }
}

Expand Down
17 changes: 14 additions & 3 deletions apps/chat-server/src/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ export interface UploadImage {
height?: number
}

/** Quotes/mentions that ride along with an upload send (t182). Mirrors the reply path's opts so an
* attachment can carry a quoted reply and real @mentions instead of silently dropping both. `html`
* is the composer's pre-built rich caption — it is sent VERBATIM, which is the only way per-token
* mention spans survive to the wire. */
export interface UploadOpts {
text?: string
html?: string | null
quotes?: ReplyRef[]
mentions?: MentionRef[]
}

/** The result of an upload send: the new message's id (arrival ms as string). */
export interface UploadResult {
ok: true
Expand Down Expand Up @@ -99,12 +110,12 @@ export interface ChatProvider {
/** Flag the conversation unread from `ts` on, service-side (PSN-102). */
markUnread(convId: string, ts: number): Promise<void>
roster(convId: string): Promise<RosterMember[]>
uploadImage(convId: string, image: UploadImage, text?: string): Promise<UploadResult>
uploadImages(convId: string, images: UploadImage[], text?: string): Promise<UploadResult>
uploadImage(convId: string, image: UploadImage, opts?: UploadOpts): Promise<UploadResult>
uploadImages(convId: string, images: UploadImage[], opts?: UploadOpts): Promise<UploadResult>
uploadFile(
convId: string,
file: { filename: string; base64: string; contentType?: string },
text?: string,
opts?: UploadOpts,
): Promise<UploadResult>
profile(userId: string): Promise<ChatProfile>
avatar(userId: string, size?: string): Promise<AvatarResult>
Expand Down
44 changes: 38 additions & 6 deletions apps/chat-server/src/providers/teams-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
ProviderSearchHit,
ProviderSearchPage,
UploadImage,
UploadOpts,
UploadResult,
} from "./provider.ts"
import { ProviderError } from "./provider.ts"
Expand Down Expand Up @@ -160,22 +161,53 @@ export class TeamsProvider implements ChatProvider {
}))
}

async uploadImage(convId: string, image: UploadImage, text?: string): Promise<UploadResult> {
const out = await this.call<{ msgId?: string }>("upload-image", { convId, ...image, text })
// Mentions cross the internal boundary in Teams-native shape (`mri`), same mapping the reply
// path uses. Shared so an upload send carries them too (t182).
private uploadWire(opts?: UploadOpts) {
return {
text: opts?.text,
html: opts?.html ?? null,
quotes: opts?.quotes ?? [],
mentions: (opts?.mentions ?? []).map((m) => ({
itemid: m.itemid,
mri: m.id,
displayName: m.displayName,
})),
}
}

async uploadImage(convId: string, image: UploadImage, opts?: UploadOpts): Promise<UploadResult> {
const out = await this.call<{ msgId?: string }>("upload-image", {
convId,
...image,
...this.uploadWire(opts),
})
return { ok: true, msgId: String(out.msgId ?? "") }
}

async uploadImages(convId: string, images: UploadImage[], text?: string): Promise<UploadResult> {
const out = await this.call<{ msgId?: string }>("upload-images", { convId, images, text })
async uploadImages(
convId: string,
images: UploadImage[],
opts?: UploadOpts,
): Promise<UploadResult> {
const out = await this.call<{ msgId?: string }>("upload-images", {
convId,
images,
...this.uploadWire(opts),
})
return { ok: true, msgId: String(out.msgId ?? "") }
}

async uploadFile(
convId: string,
file: { filename: string; base64: string; contentType?: string },
text?: string,
opts?: UploadOpts,
): Promise<UploadResult> {
const out = await this.call<{ msgId?: string }>("upload-file", { convId, ...file, text })
const out = await this.call<{ msgId?: string }>("upload-file", {
convId,
...file,
...this.uploadWire(opts),
})
return { ok: true, msgId: String(out.msgId ?? "") }
}

Expand Down
17 changes: 14 additions & 3 deletions apps/chat-server/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import type {
ChatMessage,
ChatService,
ChatWsServerMessage,
MentionRef,
ReplyRef,
ReplySuggestionBatch,
SearchHit,
SearchPage,
Expand Down Expand Up @@ -243,6 +245,15 @@ export function createRoutes(deps: RoutesDeps) {
return c.json({ members })
})

// Quotes/mentions are threaded through the upload paths so an attachment send carries them
// instead of silently dropping both (t182).
const uploadOpts = (b: Record<string, unknown>) => ({
text: b.text as string | undefined,
html: (b.html as string | null | undefined) ?? null,
quotes: (b.quotes as ReplyRef[] | undefined) ?? [],
mentions: (b.mentions as MentionRef[] | undefined) ?? [],
})

app.post("/upload-image", async (c) => {
const b = await readBody(c)
const { provider } = pick(deps, b.service)
Expand All @@ -255,15 +266,15 @@ export function createRoutes(deps: RoutesDeps) {
width: b.width,
height: b.height,
},
b.text,
uploadOpts(b),
)
return c.json(r)
})

app.post("/upload-images", async (c) => {
const b = await readBody(c)
const { provider } = pick(deps, b.service)
const r = await provider.uploadImages(b.convId, b.images ?? [], b.text)
const r = await provider.uploadImages(b.convId, b.images ?? [], uploadOpts(b))
return c.json(r)
})

Expand All @@ -273,7 +284,7 @@ export function createRoutes(deps: RoutesDeps) {
const r = await provider.uploadFile(
b.convId,
{ filename: b.filename, base64: b.base64, contentType: b.contentType },
b.text,
uploadOpts(b),
)
return c.json(r)
})
Expand Down
1 change: 1 addition & 0 deletions chat/src/chat-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,7 @@ export function ChatApp() {
onDraftReply={draftReplyAction}
onFocusChange={isActive ? setThreadFocus : undefined}
onNameResolved={onNameResolved}
onOpenMessageLink={openCitation}
onOpenProfile={setProfileTarget}
onSummarizeConv={summarizeConvAction}
onToggleInfo={isActive && isWide ? toggleInfo : undefined}
Expand Down
Loading