feat: federated events

Co-authored-by: Kainoa Kanter <kainoa@t1c.dev>
This commit is contained in:
Sam Smucny 2023-06-21 23:51:11 -07:00 committed by ThatOneCalculator
parent 4bd0838f29
commit 834671839f
No known key found for this signature in database
GPG key ID: 8703CACD01000000
22 changed files with 974 additions and 20 deletions

View file

@ -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"

View file

@ -1931,3 +1931,15 @@ isBot: このアカウントはBotです
isLocked: このアカウントのフォローは承認制です
isAdmin: 管理者
isPatron: Calckey 後援者
event: "イベント"
events: "イベント"
_event:
title: "題名"
startDateTime: "開始日時"
endDateTime: "終了日時"
startDate: "開始日"
endDate: "終了日"
startTime: "開始時刻"
endTime: "終了時刻"
detailName: "属性"
detailValue: "値"

View file

@ -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,

View file

@ -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<Event>) {
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;
};

View file

@ -61,6 +61,11 @@ export class Note {
})
public threadId: string | null;
@Column("boolean", {
default: false,
})
public hasEvent: boolean;
@Column("text", {
nullable: true,
})

View file

@ -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);

View file

@ -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<boolean> {
// 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_),

View file

@ -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<IEvent> {
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");
}
}

View file

@ -387,6 +387,7 @@ export async function createNote(
apHashtags,
apEmojis,
poll,
event,
uri: note.id,
url: url,
},

View file

@ -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!,
);

View file

@ -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,
};

View file

@ -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;

View file

@ -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,

View file

@ -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<Event> = {};
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<string, User> = {};
mentionedUsers.forEach((u) => {
mentionedUserLookup[u.id] = u;

View file

@ -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);

View file

@ -159,6 +159,12 @@ export type Note = {
votes: number;
}[];
};
event?: {
title: string,
start: DateString,
end: DateString | null,
metadata: Record<string, string>,
};
emojis: {
name: string;
url: string;

View file

@ -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);
}

View file

@ -0,0 +1,154 @@
<template>
<div :class="$style.container">
<div :class="$style.title">
<i class="ph-calendar-blank ph-bold ph-xl"></i>
{{ props.note.event!.title }}
</div>
<dl :class="$style.details">
<dt :class="$style.key">{{ i18n.ts._event.startDateTime }}</dt>
<dd :class="$style.value">
<MkTime :time="props.note.event!.start" mode="detail" />
</dd>
<template v-if="props.note.event!.end">
<dt :class="$style.key">{{ i18n.ts._event.endDateTime }}</dt>
<dd :class="$style.value">
<MkTime :time="props.note.event!.end" mode="detail" />
</dd>
</template>
<template v-if="props.note.event!.metadata.doorTime">
<dt :class="$style.key">{{ i18n.ts._event.doorTime }}</dt>
<dd :class="$style.value">
{{ props.note.event!.metadata.doorTime }}
</dd>
</template>
<template v-if="props.note.event!.metadata.location">
<dt :class="$style.key">{{ i18n.ts._event.location }}</dt>
<dd :class="$style.value">
{{ props.note.event!.metadata.location }}
</dd>
</template>
<template v-if="props.note.event!.metadata.url">
<dt :class="$style.key">{{ i18n.ts._event.url }}</dt>
<dd :class="$style.value">
<a :href="props.note.event!.metadata.url">{{
props.note.event!.metadata.url
}}</a>
</dd>
</template>
<template v-if="props.note.event!.metadata.organizer">
<dt :class="$style.key">{{ i18n.ts._event.organizer }}</dt>
<dd :class="$style.value">
{{ props.note.event!.metadata.organizer.name }}
</dd>
</template>
<template v-if="props.note.event!.metadata.audience">
<dt :class="$style.key">{{ i18n.ts._event.audience }}</dt>
<dd :class="$style.value">
{{ props.note.event!.metadata.audience.name }}
</dd>
</template>
<template v-if="props.note.event!.metadata.inLanguage">
<dt :class="$style.key">{{ i18n.ts._event.language }}</dt>
<dd :class="$style.value">
{{ props.note.event!.metadata.inLanguage }}
</dd>
</template>
<template v-if="props.note.event!.metadata.typicalAgeRange">
<dt :class="$style.key">{{ i18n.ts._event.ageRange }}</dt>
<dd :class="$style.value">
{{ props.note.event!.metadata.typicalAgeRange }}
</dd>
</template>
<template v-if="props.note.event!.metadata.performer">
<dt :class="$style.key">{{ i18n.ts._event.performers }}</dt>
<dd :class="$style.value">
{{ props.note.event!.metadata.performer.join(", ") }}
</dd>
</template>
<template v-if="props.note.event!.metadata.offers?.url">
<dt :class="$style.key">{{ i18n.ts._event.ticketsUrl }}</dt>
<dd :class="$style.value">
<a :href="props.note.event!.metadata.offers.url">{{
props.note.event!.metadata.offers.url
}}</a>
</dd>
</template>
<template v-if="props.note.event!.metadata.isAccessibleForFree">
<dt :class="$style.key">{{ i18n.ts._event.isFree }}</dt>
<dd :class="$style.value">{{ i18n.ts.yes }}</dd>
</template>
<template v-if="props.note.event!.metadata.offers?.price">
<dt :class="$style.key">{{ i18n.ts._event.price }}</dt>
<dd :class="$style.value">
{{ props.note.event!.metadata.offers.price }}
</dd>
</template>
<template
v-if="props.note.event!.metadata.offers?.availabilityStarts || props.note.event!.metadata.offers?.availabilityEnds"
>
<dt :class="$style.key">{{ i18n.ts._event.availability }}</dt>
<dd :class="$style.value">
{{
[
props.note.event!.metadata.offers.availabilityStarts
? i18n.ts._event.from +
props.note.event!.metadata.offers
.availabilityStarts
: "",
props.note.event!.metadata.offers.availabilityEnds
? i18n.ts._event.until +
props.note.event!.metadata.offers
.availabilityEnds
: "",
].join(" ")
}}
</dd>
</template>
<template v-if="props.note.event!.metadata.keywords">
<dt :class="$style.key">{{ i18n.ts._event.keywords }}</dt>
<dd :class="$style.value">
{{ props.note.event!.metadata.keywords }}
</dd>
</template>
</dl>
</div>
</template>
<script lang="ts" setup>
import * as misskey from "calckey-js";
import { i18n } from "@/i18n";
const props = defineProps<{
note: misskey.entities.Note;
}>();
</script>
<style lang="scss" module>
.container {
background: var(--bg);
border-radius: var(--radius);
padding: 1rem;
}
.title {
font-size: 1.6rem;
line-height: 1.25;
font-weight: bold;
padding-bottom: 1rem;
border-bottom: 0.5px solid var(--divider);
}
.details {
display: grid;
grid-template-columns: auto 1fr;
grid-gap: 1rem;
padding-top: 1rem;
margin: 0;
}
.key {
opacity: 0.75;
}
.value {
margin: 0;
opacity: 0.75;
}
</style>

View file

@ -0,0 +1,361 @@
<template>
<div class="zmdxowut">
<MkInput v-model="title" small type="text" class="input">
<template #label>*{{ i18n.ts._event.title }}</template>
</MkInput>
<section>
<div>
<section>
<MkInput
v-model="startDate"
small
type="date"
class="input"
>
<template #label
>*{{ i18n.ts._event.startDate }}</template
>
</MkInput>
</section>
<section>
<MkInput
v-model="startTime"
small
type="time"
class="input"
>
<template #label
>*{{ i18n.ts._event.startTime }}</template
>
</MkInput>
</section>
<section>
<MkInput v-model="endDate" small type="date" class="input">
<template #label>{{ i18n.ts._event.endDate }}</template>
</MkInput>
</section>
<section>
<MkInput v-model="endTime" small type="time" class="input">
<template #label>{{ i18n.ts._event.endTime }}</template>
</MkInput>
</section>
<section>
<MkInput v-model="location" small type="text" class="input">
<template #label>{{
i18n.ts._event.location
}}</template>
</MkInput>
</section>
<section>
<MkInput v-model="url" small type="url" class="input">
<template #label>{{ i18n.ts._event.url }}</template>
</MkInput>
</section>
</div>
<div>
<section>
<MkSwitch
v-model="showAdvanced"
:disabled="false"
class="input"
>{{ i18n.ts.advanced }}</MkSwitch
>
</section>
</div>
<div v-show="showAdvanced">
<section>
<MkInput v-model="doorTime" small type="time" class="input">
<template #label>{{
i18n.ts._event.doorTime
}}</template>
</MkInput>
</section>
<section>
<MkInput
v-model="organizer"
small
type="text"
class="input"
>
<template #label>{{
i18n.ts._event.organizer
}}</template>
</MkInput>
</section>
<section>
<MkInput
v-model="organizerLink"
small
type="url"
class="input"
>
<template #label>{{
i18n.ts._event.organizerLink
}}</template>
</MkInput>
</section>
<section>
<MkInput v-model="audience" small type="text" class="input">
<template #label>{{
i18n.ts._event.audience
}}</template>
</MkInput>
</section>
<section>
<MkInput v-model="language" small type="text" class="input">
<template #label>{{
i18n.ts._event.language
}}</template>
</MkInput>
</section>
<section>
<MkInput v-model="ageRange" small type="text" class="input">
<template #label>{{
i18n.ts._event.ageRange
}}</template>
</MkInput>
</section>
<!--<section>
<MkInput v-model="performers" small type="text" class="input">
<template #label>{{ i18n.ts._event.performers }}</template>
</MkInput>
</section>-->
<section>
<MkInput
v-model="ticketsUrl"
small
type="url"
class="input"
>
<template #label>{{
i18n.ts._event.ticketsUrl
}}</template>
</MkInput>
</section>
<section>
<MkSwitch v-model="isFree" :disabled="false">
{{ i18n.ts._event.isFree }}
</MkSwitch>
</section>
<section>
<MkInput v-model="price" small type="text" class="input">
<template #label>{{ i18n.ts._event.price }}</template>
</MkInput>
</section>
<section>
<MkInput
v-model="availabilityStart"
small
type="datetime-local"
class="input"
>
<template #label>{{
i18n.ts._event.availabilityStart
}}</template>
</MkInput>
</section>
<section>
<MkInput
v-model="availabilityEnd"
small
type="datetime-local"
class="input"
>
<template #label>{{
i18n.ts._event.availabilityEnd
}}</template>
</MkInput>
</section>
<section>
<MkInput v-model="keywords" small type="text" class="input">
<template #label>{{
i18n.ts._event.keywords
}}</template>
</MkInput>
</section>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import * as misskey from "calckey-js";
import { Ref, ref, watch } from "vue";
import MkInput from "./MkInput.vue";
import MkSwitch from "./MkSwitch.vue";
import { formatDateTimeString } from "@/scripts/format-time-string";
import { addTime } from "@/scripts/time";
import { i18n } from "@/i18n";
const props = defineProps<{
modelValue: misskey.entities.Note["event"];
}>();
const emit = defineEmits<{
(
ev: "update:modelValue",
v: {
model: misskey.entities.Note["event"];
}
);
}>();
const title = ref(props.modelValue?.title ?? null);
const startDate = ref(
formatDateTimeString(addTime(new Date(), 1, "day"), "yyyy-MM-dd")
);
const startTime = ref("00:00");
const endDate = ref("");
const endTime = ref("");
const location = ref(props.modelValue?.metadata.location ?? null);
const url = ref(props.modelValue?.metadata.url ?? null);
const showAdvanced = ref(false);
const doorTime = ref(props.modelValue?.metadata.doorTime ?? null);
const organizer = ref(props.modelValue?.metadata.organizer?.name ?? null);
const organizerLink = ref(props.modelValue?.metadata.organizer?.sameAs ?? null);
const audience = ref(props.modelValue?.metadata.audience?.name ?? null);
const language = ref(props.modelValue?.metadata.inLanguage ?? null);
const ageRange = ref(props.modelValue?.metadata.typicalAgeRange ?? null);
const ticketsUrl = ref(props.modelValue?.metadata.offers?.url ?? null);
const isFree = ref(props.modelValue?.metadata.isAccessibleForFree ?? false);
const price = ref(props.modelValue?.metadata.offers?.price ?? null);
const availabilityStart = ref(
props.modelValue?.metadata.offers?.availabilityStarts ?? null
);
const availabilityEnd = ref(
props.modelValue?.metadata.offers?.availabilityEnds ?? null
);
const keywords = ref(props.modelValue?.metadata.keywords ?? null);
function get(): misskey.entities.Note["event"] {
const calcAt = (date: Ref<string>, time: Ref<string>): number =>
new Date(`${date.value} ${time.value}`).getTime();
const start = calcAt(startDate, startTime);
const end = endDate.value ? calcAt(endDate, endTime) : null;
return {
title: title.value,
start: start,
end: end,
metadata: {
"@type": "Event",
name: title.value,
startDate: new Date(start).toISOString(),
endDate: end ? new Date(end).toISOString() : undefined,
location: location.value ?? undefined,
url: url.value ?? undefined,
doorTime: doorTime.value ?? undefined,
organizer: organizer.value
? {
"@type": "Thing",
name: organizer.value,
sameAs: organizerLink.value ?? undefined,
}
: undefined,
audience: audience.value
? {
"@type": "Audience",
name: audience.value,
}
: undefined,
inLanguage: language.value ?? undefined,
typicalAgeRange: ageRange.value ?? undefined,
isAccessibleForFree: isFree,
offers:
ticketsUrl.value || price.value
? {
price: price.value ?? undefined,
priceCurrency: undefined,
availabilityStarts:
availabilityStart.value ?? undefined,
availabilityEnds:
availabilityEnd.value ?? undefined,
url: ticketsUrl.value ?? undefined,
}
: undefined,
keywords: keywords.value ?? undefined,
},
};
}
watch(
[
title,
startDate,
startTime,
endDate,
endTime,
location,
url,
doorTime,
organizer,
organizerLink,
audience,
language,
ageRange,
ticketsUrl,
isFree,
price,
availabilityStart,
availabilityEnd,
keywords,
],
() => emit("update:modelValue", get()),
{
deep: true,
}
);
</script>
<style lang="scss" scoped>
.zmdxowut {
padding: 8px 16px;
> .caution {
margin: 0 0 8px 0;
font-size: 0.8em;
color: var(--warn);
> i {
margin-right: 4px;
}
}
> ul {
display: block;
margin: 0;
padding: 0;
list-style: none;
> li {
display: flex;
margin: 8px 0;
padding: 0;
width: 100%;
> .input {
flex: 1;
}
> button {
width: 32px;
padding: 4px 0;
}
}
}
> section {
margin: 16px 0 0 0;
> div {
margin: 0 8px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
&:last-child {
flex: 1 0 auto;
> div {
flex-grow: 1;
}
> section {
// MAGIC: Prevent div above from growing unless wrapped to its own line
flex-grow: 9999;
align-items: end;
display: flex;
gap: 4px;
> .input {
flex: 1 1 auto;
}
}
}
}
}
}
</style>

View file

@ -149,6 +149,7 @@
@changeName="updateFileName"
/>
<XPollEditor v-if="poll" v-model="poll" @destroyed="poll = null" />
<XEventEditor v-if="event" v-model="event" @destroyed="event = null" />
<XNotePreview v-if="showPreview" class="preview" :text="text" />
<footer>
<button
@ -166,6 +167,14 @@
>
<i class="ph-microphone-stage ph-bold ph-lg"></i>
</button>
<button
v-tooltip="i18n.ts.event"
class="_button"
:class="{ active: event }"
@click="toggleEvent"
>
<i class="ph-microphone-stage ph-bold ph-lg"></i>
</button>
<button
v-tooltip="i18n.ts.useCw"
class="_button"
@ -237,6 +246,7 @@ import XNoteSimple from "@/components/MkNoteSimple.vue";
import XNotePreview from "@/components/MkNotePreview.vue";
import XPostFormAttaches from "@/components/MkPostFormAttaches.vue";
import XPollEditor from "@/components/MkPollEditor.vue";
import XEventEditor from "@/components/MkEventEditor.vue";
import { host, url } from "@/config";
import { erase, unique } from "@/scripts/array";
import { extractMentions } from "@/scripts/extract-mentions";
@ -307,6 +317,12 @@ let poll = $ref<{
expiresAt: string | null;
expiredAfter: string | null;
} | null>(null);
let event = $ref<{
title: string;
start: string;
end: string | null;
metadata: Record<string, string>;
} | null>(null);
let useCw = $ref(false);
let showPreview = $ref(false);
let cw = $ref<string | null>(null);
@ -398,7 +414,7 @@ const maxTextLength = $computed((): number => {
const canPost = $computed((): boolean => {
return (
!posting &&
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
(1 <= textLength || 1 <= files.length || !!poll || !!event || !!props.renote) &&
textLength <= maxTextLength &&
(!poll || poll.choices.length >= 2)
);
@ -521,6 +537,7 @@ function watchForDraft() {
watch($$(useCw), () => saveDraft());
watch($$(cw), () => saveDraft());
watch($$(poll), () => saveDraft());
watch($$(event), () => saveDraft());
watch($$(files), () => saveDraft(), { deep: true });
watch($$(visibility), () => saveDraft());
watch($$(localOnly), () => saveDraft());
@ -575,6 +592,19 @@ function togglePoll() {
}
}
function toggleEvent() {
if (event) {
event = null;
} else {
event = {
title: '',
start: (new Date()).toString(),
end: null,
metadata: {},
};
}
}
function addTag(tag: string) {
insertTextAtCursor(textareaEl, ` #${tag} `);
}

View file

@ -116,6 +116,7 @@
:media-list="note.files"
/>
<XPoll v-if="note.poll" :note="note" class="poll" />
<XEvent v-if="note.event" :note="note" />
<template v-if="detailed">
<MkUrlPreview
v-for="url in urls"
@ -183,6 +184,7 @@ import * as os from "@/os";
import XNoteSimple from "@/components/MkNoteSimple.vue";
import XMediaList from "@/components/MkMediaList.vue";
import XPoll from "@/components/MkPoll.vue";
import XEvent from "@/components/MkEvent.vue";
import MkUrlPreview from "@/components/MkUrlPreview.vue";
import XShowMoreButton from "@/components/MkShowMoreButton.vue";
import XCwButton from "@/components/MkCwButton.vue";

View file

@ -377,7 +377,7 @@ export default defineComponent({
this.author &&
this.author.host != null
? this.author.host
: token.props.host) || host,
: token.props.host) ?? host,
username: token.props.username,
}),
];