From 834671839f73d71d6e565698136d5d72a73f1df8 Mon Sep 17 00:00:00 2001 From: Sam Smucny Date: Wed, 21 Jun 2023 23:51:11 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20federated=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kainoa Kanter --- locales/en-US.yml | 31 ++ locales/ja-JP.yml | 12 + packages/backend/src/db/postgre.ts | 2 + packages/backend/src/models/entities/event.ts | 133 +++++++ packages/backend/src/models/entities/note.ts | 5 + packages/backend/src/models/index.ts | 2 + .../backend/src/models/repositories/note.ts | 12 + .../src/remote/activitypub/models/event.ts | 40 ++ .../src/remote/activitypub/models/note.ts | 1 + .../src/remote/activitypub/models/question.ts | 2 +- .../src/remote/activitypub/renderer/note.ts | 17 +- .../backend/src/remote/activitypub/type.ts | 18 + .../src/server/api/endpoints/notes/create.ts | 23 ++ .../src/server/api/endpoints/notes/edit.ts | 82 ++++ packages/backend/src/services/note/create.ts | 40 +- packages/calckey-js/src/entities.ts | 6 + .../src/components/MkDateSeparatedList.vue | 17 +- packages/client/src/components/MkEvent.vue | 154 ++++++++ .../client/src/components/MkEventEditor.vue | 361 ++++++++++++++++++ packages/client/src/components/MkPostForm.vue | 32 +- .../src/components/MkSubNoteContent.vue | 2 + packages/client/src/components/mfm.ts | 2 +- 22 files changed, 974 insertions(+), 20 deletions(-) create mode 100644 packages/backend/src/models/entities/event.ts create mode 100644 packages/backend/src/remote/activitypub/models/event.ts create mode 100644 packages/client/src/components/MkEvent.vue create mode 100644 packages/client/src/components/MkEventEditor.vue diff --git a/locales/en-US.yml b/locales/en-US.yml index cd29cebda6..38c40113d0 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1109,6 +1109,8 @@ isLocked: "This account has follow approvals" isModerator: "Moderator" isAdmin: "Administrator" isPatron: "Calckey Patron" +event: "Event" +events: "Events" _sensitiveMediaDetection: description: "Reduces the effort of server moderation through automatically recognizing @@ -2075,3 +2077,32 @@ _experiments: _dialog: charactersExceeded: "Max characters exceeded! Current: {current}/Limit: {max}" charactersBelow: "Not enough characters! Current: {current}/Limit: {min}" + +_event: + title: "Title" + startDateTime: "Starts" + endDateTime: "Ends" + startDate: "Start Date" + endDate: "End Date" + startTime: "Start Date" + endTime: "End Time" + detailName: "Attribute" + detailValue: "Value" + location: "Location" + url: "URL" + doorTime: "Door Time" + organizer: "Organizer" + organizerLink: "Organizer Link" + audience: "Audience" + language: "Language" + ageRange: "Age Range" + ticketsUrl: "Tickets" + isFree: "Free" + price: "Price" + availability: "Availability" + from: "From" + until: "Until" + availabilityStart: "Availability Start" + availabilityEnd: "Availability End" + keywords: "Keywords" + performers: "Performers" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 617930db17..3d7002726b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1931,3 +1931,15 @@ isBot: このアカウントはBotです isLocked: このアカウントのフォローは承認制です isAdmin: 管理者 isPatron: Calckey 後援者 +event: "イベント" +events: "イベント" +_event: + title: "題名" + startDateTime: "開始日時" + endDateTime: "終了日時" + startDate: "開始日" + endDate: "終了日" + startTime: "開始時刻" + endTime: "終了時刻" + detailName: "属性" + detailValue: "値" diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index 0fa5fdff67..0a0318a277 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -40,6 +40,7 @@ import { Signin } from "@/models/entities/signin.js"; import { AuthSession } from "@/models/entities/auth-session.js"; import { FollowRequest } from "@/models/entities/follow-request.js"; import { Emoji } from "@/models/entities/emoji.js"; +import { Event } from "@/models/entities/event.js"; import { UserNotePining } from "@/models/entities/user-note-pining.js"; import { Poll } from "@/models/entities/poll.js"; import { UserKeypair } from "@/models/entities/user-keypair.js"; @@ -158,6 +159,7 @@ export const entities = [ PollVote, Notification, Emoji, + Event, Hashtag, SwSubscription, AbuseUserReport, diff --git a/packages/backend/src/models/entities/event.ts b/packages/backend/src/models/entities/event.ts new file mode 100644 index 0000000000..13d631c97d --- /dev/null +++ b/packages/backend/src/models/entities/event.ts @@ -0,0 +1,133 @@ +import { + Entity, + Index, + Column, + PrimaryColumn, + OneToOne, + JoinColumn, +} from "typeorm"; +import { id } from "../id.js"; +import { noteVisibilities } from "../../types.js"; +import { Note } from "./note.js"; +import type { User } from "./user.js"; + +@Entity() +export class Event { + @PrimaryColumn(id()) + public noteId: Note["id"]; + + @OneToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; + + @Index() + @Column("timestamp with time zone", { + comment: "The start time of the event", + }) + public start: Date; + + @Column("timestamp with time zone", { + comment: "The end of the event", + nullable: true, + }) + public end: Date; + + @Column({ + type: "varchar", + length: 128, + comment: "short name of event", + }) + public title: string; + + @Column("jsonb", { + default: { + "@context": "https://schema.org/", + "@type": "Event", + }, + comment: + "metadata object describing the event. Follows https://schema.org/Event", + }) + public metadata: EventSchema; + + //#region Denormalized fields + @Column("enum", { + enum: noteVisibilities, + comment: "[Denormalized]", + }) + public noteVisibility: typeof noteVisibilities[number]; + + @Index() + @Column({ + ...id(), + comment: "[Denormalized]", + }) + public userId: User["id"]; + + @Index() + @Column("varchar", { + length: 128, + nullable: true, + comment: "[Denormalized]", + }) + public userHost: string | null; + //#endregion + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} + +export type EventSchema = { + "@type": "Event"; + name?: string; + url?: string; + description?: string; + audience?: { + "@type": "Audience"; + name: string; + }; + doorTime?: string; + startDate?: string; + endDate?: string; + eventStatus?: + | "https://schema.org/EventCancelled" + | "https://schema.org/EventMovedOnline" + | "https://schema.org/EventPostponed" + | "https://schema.org/EventRescheduled" + | "https://schema.org/EventScheduled"; + inLanguage?: string; + isAccessibleForFree?: boolean; + keywords?: string; + location?: string; + offers?: { + "@type": "Offer"; + price?: string; + priceCurrency?: string; + availabilityStarts?: string; + availabilityEnds?: string; + url?: string; + }; + organizer?: { + name: string; + sameAs?: string; // ie. URL to website/social + }; + performer?: { + name: string; + sameAs?: string; // ie. URL to website/social + }[]; + typicalAgeRange?: string; + identifier?: string; +}; + +export type IEvent = { + start: Date; + end: Date | null; + title: string; + metadata: EventSchema; +}; diff --git a/packages/backend/src/models/entities/note.ts b/packages/backend/src/models/entities/note.ts index edcfdb635e..ec0d2b7aa1 100644 --- a/packages/backend/src/models/entities/note.ts +++ b/packages/backend/src/models/entities/note.ts @@ -61,6 +61,11 @@ export class Note { }) public threadId: string | null; + @Column("boolean", { + default: false, + }) + public hasEvent: boolean; + @Column("text", { nullable: true, }) diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index cfc3b01c55..e8d20a2fdb 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -6,6 +6,7 @@ import { AnnouncementRead } from "./entities/announcement-read.js"; import { Instance } from "./entities/instance.js"; import { Poll } from "./entities/poll.js"; import { PollVote } from "./entities/poll-vote.js"; +import { Event } from "./entities/event.js"; import { Meta } from "./entities/meta.js"; import { SwSubscription } from "./entities/sw-subscription.js"; import { NoteWatching } from "./entities/note-watching.js"; @@ -81,6 +82,7 @@ export const NoteReactions = NoteReactionRepository; export const NoteUnreads = db.getRepository(NoteUnread); export const Polls = db.getRepository(Poll); export const PollVotes = db.getRepository(PollVote); +export const Events = db.getRepository(Event); export const Users = UserRepository; export const UserProfiles = db.getRepository(UserProfile); export const UserKeypairs = db.getRepository(UserKeypair); diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts index 453179bd6f..877e89015b 100644 --- a/packages/backend/src/models/repositories/note.ts +++ b/packages/backend/src/models/repositories/note.ts @@ -9,6 +9,7 @@ import { NoteReactions, Followings, Polls, + Events, Channels, } from "../index.js"; import type { Packed } from "@/misc/schema.js"; @@ -95,6 +96,16 @@ async function populateMyReaction( return undefined; } +export async function populateEvent(note: Note) { + const event = await Events.findOneByOrFail({ noteId: note.id }); + return { + title: event.title, + start: event.start, + end: event.end, + metadata: event.metadata, + }; +} + export const NoteRepository = db.getRepository(Note).extend({ async isVisibleForMe(note: Note, meId: User["id"] | null): Promise { // This code must always be synchronized with the checks in generateVisibilityQuery. @@ -237,6 +248,7 @@ export const NoteRepository = db.getRepository(Note).extend({ url: note.url || undefined, updatedAt: note.updatedAt?.toISOString() || undefined, poll: note.hasPoll ? populatePoll(note, meId) : undefined, + event: note.hasEvent ? populateEvent(note) : undefined, ...(meId ? { myReaction: populateMyReaction(note, meId, options?._hint_), diff --git a/packages/backend/src/remote/activitypub/models/event.ts b/packages/backend/src/remote/activitypub/models/event.ts new file mode 100644 index 0000000000..2241831b13 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/event.ts @@ -0,0 +1,40 @@ +import config from "@/config/index.js"; +import Resolver from "../resolver.js"; +import { isEvent } from "../type.js"; +import { IEvent } from "@/models/entities/event.js"; + +export async function extractEventFromNote( + source: string | IEvent, + resolver?: Resolver, +): Promise { + if (resolver == null) resolver = new Resolver(); + + const note = await resolver.resolve(source); + + if (!isEvent(note)) { + throw new Error("invalid type"); + } + + if (note.name && note.startTime) { + const title = note.name; + const start = note.startTime; + const end = note.endTime ?? null; + + return { + title, + start, + end, + metadata: { + "@type": "Event", + name: note.name, + url: note.href, + startDate: note.startTime.toISOString(), + endDate: note.endTime?.toISOString(), + description: note.summary, + identifier: note.id, + }, + }; + } else { + throw new Error("Invalid event properties"); + } +} diff --git a/packages/backend/src/remote/activitypub/models/note.ts b/packages/backend/src/remote/activitypub/models/note.ts index 26aa5bf544..221bcb5570 100644 --- a/packages/backend/src/remote/activitypub/models/note.ts +++ b/packages/backend/src/remote/activitypub/models/note.ts @@ -387,6 +387,7 @@ export async function createNote( apHashtags, apEmojis, poll, + event, uri: note.id, url: url, }, diff --git a/packages/backend/src/remote/activitypub/models/question.ts b/packages/backend/src/remote/activitypub/models/question.ts index f5855c3e7c..4f12a2856d 100644 --- a/packages/backend/src/remote/activitypub/models/question.ts +++ b/packages/backend/src/remote/activitypub/models/question.ts @@ -29,7 +29,7 @@ export async function extractPollFromQuestion( throw new Error("invalid question"); } - const choices = question[multiple ? "anyOf" : "oneOf"]!.map( + const choices = question[multiple ? "anyOf" : "oneOf"]?.map( (x, i) => x.name!, ); diff --git a/packages/backend/src/remote/activitypub/renderer/note.ts b/packages/backend/src/remote/activitypub/renderer/note.ts index 2ad2fec9fb..d9a6bc33a0 100644 --- a/packages/backend/src/remote/activitypub/renderer/note.ts +++ b/packages/backend/src/remote/activitypub/renderer/note.ts @@ -134,12 +134,26 @@ export default async function renderNote( name: text, replies: { type: "Collection", - totalItems: poll!.votes[i], + totalItems: poll?.votes[i], }, })), } : {}; + let asEvent = {}; + if (note.hasEvent) { + const event = await Events.findOneBy({ noteId: note.id }); + asEvent = event + ? ({ + type: "Event", + name: event.title, + startTime: event.start, + endTime: event.end, + ...event.metadata, + } as const) + : {}; + } + const asTalk = isTalk ? { _misskey_talk: true, @@ -167,6 +181,7 @@ export default async function renderNote( attachment: files.map(renderDocument), sensitive: note.cw != null || files.some((file) => file.isSensitive), tag, + ...asEvent, ...asPoll, ...asTalk, }; diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts index b0bdb0a8b4..3e6bd19cea 100644 --- a/packages/backend/src/remote/activitypub/type.ts +++ b/packages/backend/src/remote/activitypub/type.ts @@ -157,11 +157,29 @@ export interface IQuestion extends IObject { export const isQuestion = (object: IObject): object is IQuestion => getApType(object) === "Note" || getApType(object) === "Question"; +export const isEvent = (object: IObject): object is IObject => + getApType(object) === "Note" || getApType(object) === "Event"; + interface IQuestionChoice { name?: string; replies?: ICollection; _misskey_votes?: number; } +export interface IEvent extends IObject { + type: "Event"; + title?: string; + start?: Date; + end?: Date; + metadata?: { + "@type": "Event"; + name: string; + url?: string; + startDate: string; + endDate?: string; + description?: string; + identifier?: string; + }; +} export interface ITombstone extends IObject { type: "Tombstone"; formerType?: string; diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 41b8ab9796..1a40d9222c 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -150,6 +150,21 @@ export const paramDef = { }, required: ["choices"], }, + event: { + type: "object", + nullable: true, + properties: { + title: { + type: "string", + minLength: 1, + maxLength: 128, + nullable: false, + }, + start: { type: "integer", nullable: false }, + end: { type: "integer", nullable: true }, + metadata: { type: "object" }, + }, + }, }, anyOf: [ { @@ -292,6 +307,14 @@ export default define(meta, paramDef, async (ps, user) => { text: ps.text || undefined, reply, renote, + event: ps.event + ? { + start: new Date(ps.event.start), + end: ps.event.end ? new Date(ps.event.end) : null, + title: ps.event.title, + metadata: ps.event.metadata ?? {}, + } + : undefined, cw: ps.cw, localOnly: ps.localOnly, visibility: ps.visibility, diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts index 90a4f4ded1..fc7fabc401 100644 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -9,6 +9,7 @@ import { Blockings, UserProfiles, Polls, + Events, NoteEdits, } from "@/models/index.js"; import type { DriveFile } from "@/models/entities/drive-file.js"; @@ -21,6 +22,7 @@ import define from "../../define.js"; import { HOUR } from "@/const.js"; import { getNote } from "../../common/getters.js"; import { Poll } from "@/models/entities/poll.js"; +import { Event } from "@/models/entities/event.js"; import * as mfm from "mfm-js"; import { concat } from "@/prelude/array.js"; import { extractHashtags } from "@/misc/extract-hashtags.js"; @@ -93,6 +95,18 @@ export const meta = { id: "04da457d-b083-4055-9082-955525eda5a5", }, + cannotCreateAlreadyEndedEvent: { + message: "Event is already ended.", + code: "CANNOT_CREATE_ALREADY_ENDED_EVENT", + id: "dbfc7718-e764-4707-924c-0ecf8855ba7c", + }, + + cannotUpdateWithEvent: { + message: "You can not edit a post with an event (this will be fixed).", + code: "CANNOT_EDIT_EVENT", + id: "e3c0f5a0-8b0a-4b9a-9b0a-0e9b0a8b0a8b", + }, + noSuchChannel: { message: "No such channel.", code: "NO_SUCH_CHANNEL", @@ -205,6 +219,26 @@ export const paramDef = { }, required: ["choices"], }, + event: { + type: "object", + nullable: true, + properties: { + title: { type: "string", maxLength: 100 }, + start: { type: "integer" }, + end: { type: "integer", nullable: true }, + metadata : { + type: "object", + properties: { + name: { type: "string", maxLength: 100 }, + url: { type: "string", format: "uri" }, + startDate: { type: "string", format: "date-time" }, + endDate: { type: "string", format: "date-time", nullable: true }, + description: { type: "string", maxLength: 250 }, + identifier: { type: "string", format: "misskey:id" }, + }, + } + }, + }, }, anyOf: [ { @@ -496,6 +530,54 @@ export default define(meta, paramDef, async (ps, user) => { } } + if (ps.event) { + throw new ApiError(meta.errors.cannotUpdateWithEvent); + // TODO: Fix/implement properly + /* + let event = await Events.findOneBy({ noteId: note.id }); + const start = Date.now() + ps.event.start!; + let end = null; + if (ps.event.end) { + end = Date.now() + ps.event.end; + } + const pe = ps.event; + pe.metadata["@type"] = "Event"; + if (!event && pe) { + event = new Event({ + noteId: note.id, + start: new Date(start), + end: end ? new Date(end) : null, + title: pe.title, + metadata: pe.metadata, + + }); + await Events.insert(event); + publishing = true; + } else if (event && !pe) { + await Events.remove(event); + publishing = true; + } else if (event && pe) { + const eventUpdate: Partial = {}; + if (start !== pe.start) { + eventUpdate.start = pe.start; + } + if (event.end !== pe.end) { + eventUpdate.end = pe.end; + } + if (event.title !== pe.title) { + eventUpdate.title = pe.title; + } + if (event.metadata !== ps.metadata) { + eventUpdate.metadata = ps.metadata; + } + if (notEmpty(eventUpdate)) { + await Events.update(note.id, eventUpdate); + } + publishing = true; + } + */ + } + const mentionedUserLookup: Record = {}; mentionedUsers.forEach((u) => { mentionedUserLookup[u.id] = u; diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index 9696c3ccaa..915e8ab578 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -747,7 +747,7 @@ async function insertNote( // 投稿を作成 try { - if (insert.hasPoll) { + if (insert.hasPoll || insert.hasEvent) { // Start transaction await db.transaction(async (transactionalEntityManager) => { if (!data.poll) throw new Error("Empty poll data"); @@ -761,18 +761,34 @@ async function insertNote( expiresAt = data.poll.expiresAt; } - const poll = new Poll({ - noteId: insert.id, - choices: data.poll.choices, - expiresAt, - multiple: data.poll.multiple, - votes: new Array(data.poll.choices.length).fill(0), - noteVisibility: insert.visibility, - userId: user.id, - userHost: user.host, - }); + if (insert.hasPoll) { + const poll = new Poll({ + noteId: insert.id, + choices: data.poll.choices, + expiresAt: data.poll.expiresAt, + multiple: data.poll.multiple, + votes: new Array(data.poll.choices.length).fill(0), + noteVisibility: insert.visibility, + userId: user.id, + userHost: user.host, + }); - await transactionalEntityManager.insert(Poll, poll); + await transactionalEntityManager.insert(Poll, poll); + } + if (insert.hasEvent) { + const event = new Event({ + noteId: insert.id, + start: data.event.start, + end: data.event.end ?? undefined, + title: data.event.title, + metadata: data.event.metadata, + noteVisibility: insert.visibility, + userId: user.id, + userHost: user.host, + }); + + await transactionalEntityManager.insert(Event, event); + } }); } else { await Notes.insert(insert); diff --git a/packages/calckey-js/src/entities.ts b/packages/calckey-js/src/entities.ts index 5a581a54cd..2e13eb7c5c 100644 --- a/packages/calckey-js/src/entities.ts +++ b/packages/calckey-js/src/entities.ts @@ -159,6 +159,12 @@ export type Note = { votes: number; }[]; }; + event?: { + title: string, + start: DateString, + end: DateString | null, + metadata: Record, + }; emojis: { name: string; url: string; diff --git a/packages/client/src/components/MkDateSeparatedList.vue b/packages/client/src/components/MkDateSeparatedList.vue index 7ecc91808a..847b04667e 100644 --- a/packages/client/src/components/MkDateSeparatedList.vue +++ b/packages/client/src/components/MkDateSeparatedList.vue @@ -32,6 +32,11 @@ export default defineComponent({ required: false, default: false, }, + getDate: { + type: Function, // Note => date string + required: false, + default: undefined, + }, }, setup(props, { slots, expose }) { @@ -46,6 +51,9 @@ export default defineComponent({ if (props.items.length === 0) return; + const getDateKey = (item): string => + props.getDate ? props.getDate(item) : item.createdAt; + const renderChildren = () => props.items.map((item, i) => { if (!slots || !slots.default) return; @@ -57,8 +65,8 @@ export default defineComponent({ if ( i !== props.items.length - 1 && - new Date(item.createdAt).getDate() !== - new Date(props.items[i + 1].createdAt).getDate() + new Date(getDateKey(item)).getDate() !== + new Date(getDateKey(props.items[i + 1])).getDate() ) { const separator = h( "div", @@ -76,10 +84,10 @@ export default defineComponent({ h("i", { class: "ph-caret-up ph-bold ph-lg icon", }), - getDateText(item.createdAt), + getDateText(getDateKey(item)), ]), h("span", [ - getDateText(props.items[i + 1].createdAt), + getDateText(getDateKey(props.items[i + 1])), h("i", { class: "ph-caret-down ph-bold ph-lg icon", }), @@ -201,6 +209,7 @@ export default defineComponent({ &:first-child { border-radius: var(--radius) var(--radius) 0 0; } + &:last-child { border-radius: 0 0 var(--radius) var(--radius); } diff --git a/packages/client/src/components/MkEvent.vue b/packages/client/src/components/MkEvent.vue new file mode 100644 index 0000000000..977b205481 --- /dev/null +++ b/packages/client/src/components/MkEvent.vue @@ -0,0 +1,154 @@ + + + diff --git a/packages/client/src/components/MkEventEditor.vue b/packages/client/src/components/MkEventEditor.vue new file mode 100644 index 0000000000..d7fbfd5aed --- /dev/null +++ b/packages/client/src/components/MkEventEditor.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/packages/client/src/components/MkPostForm.vue b/packages/client/src/components/MkPostForm.vue index 16acfcc1c9..c947542d63 100644 --- a/packages/client/src/components/MkPostForm.vue +++ b/packages/client/src/components/MkPostForm.vue @@ -149,6 +149,7 @@ @changeName="updateFileName" /> +