feat: add conversation image previews and sharing

This commit is contained in:
jjbrunton committed 2026-08-09 22:28:41 +01:00
1 parent 488145340d
commit b573b88b08
20 files changed
+634 -26

No files matched your search

+47 -1
View File
@@ -28,6 +28,11 @@ export type MediaExchangeResult =
| { kind: 'retryable'; assetId: string; requestKind: MediaRequestKind }
| { kind: 'human-required'; reason: string; remoteSent?: boolean };
export interface ShareableMediaLibraryAssets {
requestKind: MediaRequestKind | null;
assets: MediaLibraryAsset[];
}
/** Detects inbound requests, reserves an unused asset, then sends image before reciprocal text. */
export class MediaLibraryExchangeService {
constructor(
@@ -59,6 +64,35 @@ export class MediaLibraryExchangeService {
conversationId: string,
incomingKey: string,
incomingText: string,
): Promise<MediaExchangeResult> {
return this.handleRequest(conversationId, incomingKey, incomingText);
}
listShareable(conversationId: string, incomingText: string): ShareableMediaLibraryAssets {
const prior = this.repository.listConversationSends(this.accountId, conversationId);
const requestKind = detectMediaRequest(incomingText, prior);
if (!requestKind) return { requestKind: null, assets: [] };
const used = new Set(prior.map((item) => item.assetId));
return {
requestKind,
assets: this.pickEligible(this.repository.listEligible(), used, requestKind, prior),
};
}
async handleSelected(
conversationId: string,
incomingKey: string,
incomingText: string,
assetId: string,
): Promise<MediaExchangeResult> {
return this.handleRequest(conversationId, incomingKey, incomingText, assetId);
}
private async handleRequest(
conversationId: string,
incomingKey: string,
incomingText: string,
selectedAssetId?: string,
): Promise<MediaExchangeResult> {
const prior = this.repository.listConversationSends(this.accountId, conversationId);
const requestKind = detectMediaRequest(incomingText, prior);
@@ -66,6 +100,12 @@ export class MediaLibraryExchangeService {
if (this.dryRun) return { kind: 'dry-run', requestKind };
let send = this.repository.getSend(this.accountId, conversationId, incomingKey);
if (send && selectedAssetId && send.assetId !== selectedAssetId) {
return { kind: 'human-required', reason: 'A different library image is already reserved for this request.' };
}
if (send?.status === 'sent' && send.matrixTextEventId === 'unconfirmed') {
return { kind: 'human-required', reason: 'The image was sent, but the reciprocal text was not confirmed.', remoteSent: true };
}
if (send?.status === 'sent') return { kind: 'sent', assetId: send.assetId, requestKind: send.requestKind };
if (send?.status === 'failed' || send?.status === 'superseded') {
return { kind: 'human-required', reason: 'A previous media send for this request failed or became uncertain.' };
@@ -73,7 +113,13 @@ export class MediaLibraryExchangeService {
if (!send) {
const used = new Set(prior.map((item) => item.assetId));
const eligible = this.pickEligible(this.repository.listEligible(), used, requestKind, prior);
for (const asset of shuffled(eligible, this.random)) {
const candidates = selectedAssetId
? eligible.filter((asset) => asset.id === selectedAssetId)
: shuffled(eligible, this.random);
if (selectedAssetId && candidates.length === 0) {
return { kind: 'human-required', reason: 'That library image is disabled, already used, or does not match the current request.' };
}
for (const asset of candidates) {
send = this.repository.tryReserve(asset.id, this.accountId, conversationId, incomingKey, requestKind) ?? undefined;
if (send) break;
}