feat: ✨ federated events
Co-authored-by: Kainoa Kanter <kainoa@t1c.dev>
This commit is contained in:
parent
4bd0838f29
commit
834671839f
22 changed files with 974 additions and 20 deletions
|
@ -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"
|
||||
|
|
|
@ -1931,3 +1931,15 @@ isBot: このアカウントはBotです
|
|||
isLocked: このアカウントのフォローは承認制です
|
||||
isAdmin: 管理者
|
||||
isPatron: Calckey 後援者
|
||||
event: "イベント"
|
||||
events: "イベント"
|
||||
_event:
|
||||
title: "題名"
|
||||
startDateTime: "開始日時"
|
||||
endDateTime: "終了日時"
|
||||
startDate: "開始日"
|
||||
endDate: "終了日"
|
||||
startTime: "開始時刻"
|
||||
endTime: "終了時刻"
|
||||
detailName: "属性"
|
||||
detailValue: "値"
|
||||
|
|
|
@ -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,
|
||||
|
|
133
packages/backend/src/models/entities/event.ts
Normal file
133
packages/backend/src/models/entities/event.ts
Normal 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;
|
||||
};
|
|
@ -61,6 +61,11 @@ export class Note {
|
|||
})
|
||||
public threadId: string | null;
|
||||
|
||||
@Column("boolean", {
|
||||
default: false,
|
||||
})
|
||||
public hasEvent: boolean;
|
||||
|
||||
@Column("text", {
|
||||
nullable: true,
|
||||
})
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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_),
|
||||
|
|
40
packages/backend/src/remote/activitypub/models/event.ts
Normal file
40
packages/backend/src/remote/activitypub/models/event.ts
Normal 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");
|
||||
}
|
||||
}
|
|
@ -387,6 +387,7 @@ export async function createNote(
|
|||
apHashtags,
|
||||
apEmojis,
|
||||
poll,
|
||||
event,
|
||||
uri: note.id,
|
||||
url: url,
|
||||
},
|
||||
|
|
|
@ -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!,
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
154
packages/client/src/components/MkEvent.vue
Normal file
154
packages/client/src/components/MkEvent.vue
Normal 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>
|
361
packages/client/src/components/MkEventEditor.vue
Normal file
361
packages/client/src/components/MkEventEditor.vue
Normal 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>
|
|
@ -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} `);
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
];
|
||||
|
|
Loading…
Reference in a new issue