diff --git a/src/misc/get-quote.ts b/src/misc/get-quote.ts new file mode 100644 index 00000000000..d77837a2b63 --- /dev/null +++ b/src/misc/get-quote.ts @@ -0,0 +1,92 @@ +import { removeNull, toArray } from '../prelude/array'; +import { IObject, IPost, isLink } from '../remote/activitypub/type'; + +/** + * rels treated as quote + */ +const relQuotes = new Set([ + 'https://misskey-hub.net/ns#_misskey_quote', + 'http://fedibird.com/ns#quoteUri', +]); + +/** + * Misskey like quote + */ +type Quote = { + /** Target AP object ID */ + href: string; + /** Fallback text */ + name?: string; +}; + +/** + * Get one Misskey like quote + */ +export function getQuote(post: IPost): Quote | null { + // Misskey + if (typeof post._misskey_quote === 'string') return { href: post._misskey_quote } + // Fedibird + if (typeof post.quoteUri === 'string') return { href: post.quoteUri } + + // FEP-e232: Object Links + // https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md + const fepe232Tags = parseFepE232Tags(toArray(post.tag)); + const fepe232Quote = fepe232Tags.filter(x => x.rel && relQuotes.has(x.rel))[0]; + if (fepe232Quote) { + return { + href: fepe232Quote.href, + name: fepe232Quote.name, + }; + } + + return null; +} + +/** + * Get AP links (experimental) + */ +export function getApLinks(post: IPost) { + const fepe232Tags = parseFepE232Tags(toArray(post.tag)); + + // other attachements? + + return fepe232Tags; +} + +//#region FEP-e232 +type FepE232Tag = { + type: 'Link'; + mediaType: string; + href: string; + name?: string; + rel?: string; +}; + +function parseFepE232Tags(tags: IObject[]): FepE232Tag[] { + return removeNull(tags.map(x => parseFepE232Tag(x))); +} + +function parseFepE232Tag(tag: IObject): FepE232Tag | null { + if (!isLink(tag)) return null; + if (!validateContentType(tag.mediaType)) return null; + if (typeof tag.href !== 'string') return null; + + return { + type: tag.type, + mediaType: tag.mediaType, + href: tag.href, + name: typeof tag.name === 'string' ? tag.name : undefined, + rel: typeof tag.rel === 'string' ? tag.rel : undefined, + }; +} + +function validateContentType(contentType: unknown): contentType is string { + if (contentType == null) return false; + if (typeof contentType !== 'string') return false; + + const parts = contentType.split(/\s*;\s*/); + if (parts[0] === 'application/activity+json') return true; + if (parts[0] !== 'application/ld+json') return false; + return parts.slice(1).some(part => part.trim() === 'profile="https://www.w3.org/ns/activitystreams"'); +} +//#endregion diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts index 18205d3ea4b..e526b762b68 100644 --- a/src/remote/activitypub/models/note.ts +++ b/src/remote/activitypub/models/note.ts @@ -23,6 +23,7 @@ import { parseAudience } from '../audience'; import DbResolver from '../db-resolver'; import { parseDate, parseDateWithLimit } from '../misc/date'; import { StatusError } from '../../../misc/fetch'; +import { getQuote } from '../../../misc/get-quote'; const logger = apLogger; @@ -113,8 +114,8 @@ export async function createNote(value: string | IObject, resolver?: Resolver | const reply = note.inReplyTo ? await resolveNote(getOneApId(note.inReplyTo), resolver) : null; // 引用 - const q = note._misskey_quote || note.quoteUri || note.quoteUrl; - const quote = q ? await resolveNote(q, resolver) : null; + const quoteInfo = getQuote(note); + const quote = quoteInfo?.href ? await resolveNote(quoteInfo?.href, resolver) : null; // 参照 const references = await fetchReferences(note, resolver).catch(() => []); @@ -127,6 +128,8 @@ export async function createNote(value: string | IObject, resolver?: Resolver | : note.content ? htmlToMfm(note.content, note.tag) : null; + if (text && quoteInfo?.name) text.replace(quoteInfo.name, ''); + // 投票 if (reply && reply.poll) { const tryCreateVote = async (name: string, index: number): Promise => { diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts index 1d7f5c7464d..3e631cf63ef 100644 --- a/src/remote/activitypub/models/person.ts +++ b/src/remote/activitypub/models/person.ts @@ -607,7 +607,7 @@ export async function fetchOutbox(user: IUser) { // Note if (object.inReplyTo) { // skip reply - } else if (object._misskey_quote || object.quoteUri || object.quoteUrl) { + } else if (object._misskey_quote || object.quoteUri) { // skip quote } else { if (++itemCount > 10) break; diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts index ce8044ad9bd..835ba9af683 100644 --- a/src/remote/activitypub/renderer/note.ts +++ b/src/remote/activitypub/renderer/note.ts @@ -106,10 +106,19 @@ export default async function renderNote(note: INote, dive = true): Promise const emojis = await getEmojis(note.emojis); const apemojis = emojis.map(emoji => renderEmoji(emoji)); + const fepE232Quote = { + type: 'Link', + mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + rel: 'https://misskey-hub.net/ns#_misskey_quote', + href: quote, + name: `RE: ${quote}`, + }; + const tag = [ ...hashtagTags, ...mentionTags, ...apemojis, + fepE232Quote, ]; const { diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts index 64738c3cd20..91d52e6c43c 100644 --- a/src/remote/activitypub/type.ts +++ b/src/remote/activitypub/type.ts @@ -141,7 +141,6 @@ export interface IPost extends IObject { mediaType?: string; }; _misskey_quote?: string; - quoteUrl?: string; quoteUri?: string; references: string | ICollection; } @@ -168,7 +167,6 @@ export interface IQuestion extends IObject { mediaType?: string; }; _misskey_quote?: string; - quoteUrl?: string; quoteUri?: string; oneOf?: IQuestionChoice[]; anyOf?: IQuestionChoice[]; @@ -230,6 +228,16 @@ export const isHashtag = (object: IObject): object is IApHashtag => getApType(object) === 'Hashtag' && typeof object.name === 'string'; +export interface IApLink extends IObject { + type: 'Link'; + href?: string; + rel?: string; + mediaType?: string; + name?: string; +}; + +export const isLink = (object: IObject): object is IApLink => getApType(object) === 'Link'; + export interface IActor extends IObject { type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application'; name?: string; diff --git a/test/get-quote.ts b/test/get-quote.ts new file mode 100644 index 00000000000..4049980f861 --- /dev/null +++ b/test/get-quote.ts @@ -0,0 +1,95 @@ +import * as assert from 'assert'; +import { getQuote } from '../src/misc/get-quote'; + +describe('getQuote', () => { + it('_misskey_quote', () => { + assert.deepStrictEqual(getQuote({ + _misskey_quote: 'https://example.com/notes/quote', + } as any), { + href: 'https://example.com/notes/quote', + }); + }); + + it('quoteUri', () => { + assert.deepStrictEqual(getQuote({ + quoteUri: 'https://example.com/notes/quote', + } as any), { + href: 'https://example.com/notes/quote', + }); + }); + + it('FEP-e232', () => { + assert.deepStrictEqual(getQuote({ + tag: [ + { + type: 'Link', + mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + rel: 'https://misskey-hub.net/ns#_misskey_quote', + href: 'https://example.com/notes/quote', + name: 'RE: https://example.com/html/quote', + } + ], + } as any), { + href: 'https://example.com/notes/quote', + name: 'RE: https://example.com/html/quote', + }); + }); + + it('FEP-e232 application/activity+json', () => { + assert.deepStrictEqual(getQuote({ + tag: [ + { + type: 'Link', + mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + rel: 'https://misskey-hub.net/ns#_misskey_quote', + href: 'https://example.com/notes/quote', + name: 'RE: https://example.com/html/quote', + } + ], + } as any), { + href: 'https://example.com/notes/quote', + name: 'RE: https://example.com/html/quote', + }); + }); + + it('FEP-e232 invalid mediaType', () => { + assert.deepStrictEqual(getQuote({ + tag: [ + { + type: 'Link', + mediaType: 'text/html', + rel: 'https://misskey-hub.net/ns#_misskey_quote', + href: 'https://example.com/notes/quote', + name: 'RE: https://example.com/html/quote', + } + ], + } as any), null); + }); + + it('FEP-e232 no Link', () => { + assert.deepStrictEqual(getQuote({ + tag: [ + { + type: 'Link2', + mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + rel: 'https://misskey-hub.net/ns#_misskey_quote', + href: 'https://example.com/notes/quote', + name: 'RE: https://example.com/html/quote', + } + ], + } as any), null); + }); + + it('FEP-e232 no match rel', () => { + assert.deepStrictEqual(getQuote({ + tag: [ + { + type: 'Link', + mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + href: 'https://example.com/notes/quote', + name: 'RE: https://example.com/html/quote', + } + ], + } as any), null); + }); +});