diff --git a/locales/en-US.yml b/locales/en-US.yml index 6d32668bf..614f8967b 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1340,3 +1340,7 @@ _translationService: _libreTranslate: endpoint: "LibreTranslate API Endpoint" authKey: "LibreTranslate Auth Key (optional)" +_remoteInteract: + title: "I'm sorry, I'm afraid I can't do that." + description: "You cannot perform this action right now. You probably need to do it on your own instance, or sign in." + urlInstructions: "You can copy this URL. If you paste it into the search field on your instance, you should be taken to the right location." diff --git a/packages/backend/migration/1678427401214-remove-unused.js b/packages/backend/migration/1678427401214-remove-unused.js new file mode 100644 index 000000000..7bdc0581d --- /dev/null +++ b/packages/backend/migration/1678427401214-remove-unused.js @@ -0,0 +1,13 @@ +export class removeUnused1678427401214 { + name = 'removeUnused1678427401214' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedPages"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedClipId"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedClipId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedPages" character varying(512) array NOT NULL DEFAULT '{/featured,/channels,/explore,/pages,/about-misskey}'`); + } +} diff --git a/packages/backend/src/boot/index.ts b/packages/backend/src/boot/index.ts index ef98f0eea..46dc2d258 100644 --- a/packages/backend/src/boot/index.ts +++ b/packages/backend/src/boot/index.ts @@ -18,7 +18,13 @@ const ev = new Xev(); * Init process */ export async function boot(): Promise { - process.title = `FoundKey (${cluster.isPrimary ? 'master' : 'worker'})`; + if (envOption.disableClustering) { + process.title = "Foundkey"; + } else if (cluster.isPrimary) { + process.title = "Foundkey (master)"; + } else if (cluster.isWorker) { + process.title = `Foundkey (${process.env.mode})`; + } if (cluster.isPrimary || envOption.disableClustering) { await masterMain(); diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts index 9c438bcd6..28ba51195 100644 --- a/packages/backend/src/models/entities/meta.ts +++ b/packages/backend/src/models/entities/meta.ts @@ -82,17 +82,6 @@ export class Meta { }) public blockedHosts: string[]; - @Column('varchar', { - length: 512, array: true, default: '{/featured,/channels,/explore,/pages,/about-foundkey}', - }) - public pinnedPages: string[]; - - @Column({ - ...id(), - nullable: true, - }) - public pinnedClipId: Clip['id'] | null; - @Column('varchar', { length: 512, nullable: true, diff --git a/packages/backend/src/queue/processors/deliver.ts b/packages/backend/src/queue/processors/deliver.ts index 60355e39e..c581942c7 100644 --- a/packages/backend/src/queue/processors/deliver.ts +++ b/packages/backend/src/queue/processors/deliver.ts @@ -4,7 +4,6 @@ import { request } from '@/remote/activitypub/request.js'; import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc.js'; import Logger from '@/services/logger.js'; import { Instances } from '@/models/index.js'; -import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js'; import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; import { toPuny } from '@/misc/convert-host.js'; import { StatusError } from '@/misc/fetch.js'; @@ -13,8 +12,6 @@ import { DeliverJobData } from '@/queue/types.js'; const logger = new Logger('deliver'); -let latest: string | null = null; - export default async (job: Bull.Job) => { const { host } = new URL(job.data.to); const puny = toPuny(host); @@ -22,12 +19,14 @@ export default async (job: Bull.Job) => { if (await shouldSkipInstance(puny)) return 'skip'; try { - if (latest !== (latest = JSON.stringify(job.data.content, null, 2))) { - logger.debug(`delivering ${latest}`); + if (Array.isArray(job.data.content)) { + await Promise.all( + job.data.content.map(x => request(job.data.user, job.data.to, x)) + ); + } else { + await request(job.data.user, job.data.to, job.data.content); } - await request(job.data.user, job.data.to, job.data.content); - // Update stats registerOrFetchInstanceDoc(host).then(i => { Instances.update(i.id, { @@ -38,13 +37,7 @@ export default async (job: Bull.Job) => { }); fetchInstanceMetadata(i); - - instanceChart.requestSent(i.host, true); - apRequestChart.deliverSucc(); - federationChart.deliverd(i.host, true); }); - - return 'Success'; } catch (res) { // Update stats registerOrFetchInstanceDoc(host).then(i => { @@ -53,10 +46,6 @@ export default async (job: Bull.Job) => { latestStatus: res instanceof StatusError ? res.statusCode : null, isNotResponding: true, }); - - instanceChart.requestSent(i.host, false); - apRequestChart.deliverFail(); - federationChart.deliverd(i.host, false); }); if (res instanceof StatusError) { @@ -67,7 +56,7 @@ export default async (job: Bull.Job) => { return `${res.statusCode} ${res.statusMessage}`; } - // 5xx etc. + // 5xx etc., throwing an Error will make Bull retry throw new Error(`${res.statusCode} ${res.statusMessage}`); } else { // DNS error, socket error, timeout ... diff --git a/packages/backend/src/remote/activitypub/kernel/delete/note.ts b/packages/backend/src/remote/activitypub/kernel/delete/note.ts index 15c1cba8b..9f9a5cea6 100644 --- a/packages/backend/src/remote/activitypub/kernel/delete/note.ts +++ b/packages/backend/src/remote/activitypub/kernel/delete/note.ts @@ -1,5 +1,5 @@ import { CacheableRemoteUser } from '@/models/entities/user.js'; -import deleteNode from '@/services/note/delete.js'; +import { deleteNotes } from '@/services/note/delete.js'; import { getApLock } from '@/misc/app-lock.js'; import { deleteMessage } from '@/services/messages/delete.js'; import { DbResolver } from '@/remote/activitypub/db-resolver.js'; @@ -29,7 +29,7 @@ export default async function(actor: CacheableRemoteUser, uri: string): Promise< return 'skip: cant delete other actors note'; } - await deleteNode(actor, note); + await deleteNotes([note], actor); return 'ok: note deleted'; } } finally { diff --git a/packages/backend/src/remote/activitypub/kernel/undo/announce.ts b/packages/backend/src/remote/activitypub/kernel/undo/announce.ts index c06b21db3..78981b542 100644 --- a/packages/backend/src/remote/activitypub/kernel/undo/announce.ts +++ b/packages/backend/src/remote/activitypub/kernel/undo/announce.ts @@ -1,6 +1,6 @@ import { Notes } from '@/models/index.js'; import { CacheableRemoteUser } from '@/models/entities/user.js'; -import deleteNote from '@/services/note/delete.js'; +import { deleteNotes } from '@/services/note/delete.js'; import { IAnnounce, getApId } from '@/remote/activitypub/type.js'; export const undoAnnounce = async (actor: CacheableRemoteUser, activity: IAnnounce): Promise => { @@ -13,6 +13,6 @@ export const undoAnnounce = async (actor: CacheableRemoteUser, activity: IAnnoun if (!note) return 'skip: no such Announce'; - await deleteNote(actor, note); + await deleteNotes([note], actor); return 'ok: deleted'; }; diff --git a/packages/backend/src/remote/activitypub/request.ts b/packages/backend/src/remote/activitypub/request.ts index 233bc025f..cd70da69f 100644 --- a/packages/backend/src/remote/activitypub/request.ts +++ b/packages/backend/src/remote/activitypub/request.ts @@ -4,7 +4,16 @@ import { getUserKeypair } from '@/misc/keypair-store.js'; import { User } from '@/models/entities/user.js'; import { getResponse } from '@/misc/fetch.js'; import { createSignedPost, createSignedGet } from './ap-request.js'; +import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js'; +/** + * Post an activity to an inbox. Automatically updates the statistics + * on succeeded or failed delivery attempts. + * + * @param user http-signature user + * @param url The URL of the inbox. + * @param object The Activity or other object to be posted to the inbox. + */ export async function request(user: { id: User['id'] }, url: string, object: any): Promise { const body = JSON.stringify(object); @@ -22,14 +31,28 @@ export async function request(user: { id: User['id'] }, url: string, object: any }, }); - await getResponse({ - url, - method: req.request.method, - headers: req.request.headers, - body, - // don't allow redirects on the inbox - redirect: 'error', - }); + const { host } = new URL(url); + + try { + await getResponse({ + url, + method: req.request.method, + headers: req.request.headers, + body, + // don't allow redirects on the inbox + redirect: 'error', + }); + + instanceChart.requestSent(host, true); + apRequestChart.deliverSucc(); + federationChart.deliverd(host, true); + } catch (err) { + instanceChart.requestSent(host, false); + apRequestChart.deliverFail(); + federationChart.deliverd(host, false); + + throw err; + } } /** diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 8b4dfbebb..55763d875 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -104,6 +104,7 @@ import * as ep___clips_show from './endpoints/clips/show.js'; import * as ep___clips_update from './endpoints/clips/update.js'; import * as ep___drive from './endpoints/drive.js'; import * as ep___drive_files from './endpoints/drive/files.js'; +import * as ep___drive_show from './endpoints/drive/show.js'; import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; import * as ep___drive_files_checkExistence from './endpoints/drive/files/check-existence.js'; import * as ep___drive_files_create from './endpoints/drive/files/create.js'; @@ -400,6 +401,7 @@ const eps = [ ['clips/update', ep___clips_update], ['drive', ep___drive], ['drive/files', ep___drive_files], + ['drive/show', ep___drive_show], ['drive/files/attached-notes', ep___drive_files_attachedNotes], ['drive/files/check-existence', ep___drive_files_checkExistence], ['drive/files/create', ep___drive_files_create], diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 175e90206..79f598014 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -288,8 +288,6 @@ export default define(meta, paramDef, async () => { defaultLightTheme: instance.defaultLightTheme, defaultDarkTheme: instance.defaultDarkTheme, enableEmail: instance.enableEmail, - pinnedPages: instance.pinnedPages, - pinnedClipId: instance.pinnedClipId, cacheRemoteFiles: instance.cacheRemoteFiles, useStarForReactionFallback: instance.useStarForReactionFallback, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index d3637aafc..984aa2a9d 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -48,10 +48,6 @@ export const paramDef = { proxyAccountId: { type: 'string', format: 'misskey:id', nullable: true }, maintainerName: { type: 'string', nullable: true }, maintainerEmail: { type: 'string', nullable: true }, - pinnedPages: { type: 'array', items: { - type: 'string', - } }, - pinnedClipId: { type: 'string', format: 'misskey:id', nullable: true }, langs: { type: 'array', items: { type: 'string', } }, @@ -209,14 +205,6 @@ export default define(meta, paramDef, async (ps, me) => { set.langs = ps.langs.filter(Boolean); } - if (Array.isArray(ps.pinnedPages)) { - set.pinnedPages = ps.pinnedPages.filter(Boolean); - } - - if (ps.pinnedClipId !== undefined) { - set.pinnedClipId = ps.pinnedClipId; - } - if (ps.summalyProxy !== undefined) { set.summalyProxy = ps.summalyProxy; } diff --git a/packages/backend/src/server/api/endpoints/drive/show.ts b/packages/backend/src/server/api/endpoints/drive/show.ts new file mode 100644 index 000000000..3d88ed5f5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/show.ts @@ -0,0 +1,70 @@ +import { DriveFiles, DriveFolders } from '@/models/index.js'; +import define from '../../define.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; + +export const meta = { + tags: ['drive'], + + description: "Lists all folders and files in the authenticated user's drive. Folders are always listed first. The limit, if specified, is applied over the total number of elements.", + + requireCredential: true, + + kind: 'read:drive', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + oneOf: [{ + type: 'object', + optional: false, nullable: false, + ref: 'DriveFile', + }, { + type: 'object', + optional: false, nullable: false, + ref: 'DriveFolder', + }], + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const foldersQuery = makePaginationQuery(DriveFolders.createQueryBuilder('folder'), ps.sinceId, ps.untilId) + .andWhere('folder.userId = :userId', { userId: user.id }); + const filesQuery = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId) + .andWhere('file.userId = :userId', { userId: user.id }); + + if (ps.folderId) { + foldersQuery.andWhere('folder.parentId = :parentId', { parentId: ps.folderId }); + filesQuery.andWhere('file.folderId = :folderId', { folderId: ps.folderId }); + } else { + foldersQuery.andWhere('folder.parentId IS NULL'); + filesQuery.andWhere('file.folderId IS NULL'); + } + + const folders = await foldersQuery.take(ps.limit).getMany(); + + const [files, ...packedFolders] = await Promise.all([ + filesQuery.take(ps.limit - folders.length).getMany(), + ...(folders.map(folder => DriveFolders.pack(folder))), + ]); + + const packedFiles = await DriveFiles.packMany(files, { detail: false, self: true }); + + return [ + ...packedFolders, + ...packedFiles, + ]; +}); diff --git a/packages/backend/src/server/api/endpoints/following/invalidate.ts b/packages/backend/src/server/api/endpoints/following/invalidate.ts index 6287b2071..beeb1eb15 100644 --- a/packages/backend/src/server/api/endpoints/following/invalidate.ts +++ b/packages/backend/src/server/api/endpoints/following/invalidate.ts @@ -17,7 +17,7 @@ export const meta = { kind: 'write:following', - errors: ['FOLLOWER_IS_YOURSELF', 'NO_SUCH_USER', 'NOT_FOLLOWING'], + errors: ['FOLLOWER_IS_YOURSELF', 'NO_SUCH_USER', 'NOT_FOLLOWED'], res: { type: 'object', @@ -53,7 +53,7 @@ export default define(meta, paramDef, async (ps, user) => { followeeId: followee.id, }); - if (!exist) throw new ApiError('NOT_FOLLOWING'); + if (!exist) throw new ApiError('NOT_FOLLOWED'); await deleteFollowing(follower, followee); diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 2a05602f5..35c8839b4 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -305,8 +305,6 @@ export default define(meta, paramDef, async () => { translatorAvailable: translatorAvailable(instance), - pinnedPages: instance.pinnedPages, - pinnedClipId: instance.pinnedClipId, cacheRemoteFiles: instance.cacheRemoteFiles, requireSetup: (await Users.countBy({ host: IsNull(), diff --git a/packages/backend/src/server/api/endpoints/notes/delete.ts b/packages/backend/src/server/api/endpoints/notes/delete.ts index f6cf7f5ec..ee787b603 100644 --- a/packages/backend/src/server/api/endpoints/notes/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/delete.ts @@ -1,4 +1,4 @@ -import deleteNote from '@/services/note/delete.js'; +import { deleteNotes } from '@/services/note/delete.js'; import { Users } from '@/models/index.js'; import { SECOND, HOUR } from '@/const.js'; import define from '@/server/api/define.js'; @@ -47,6 +47,6 @@ export default define(meta, paramDef, async (ps, user) => { throw new ApiError('ACCESS_DENIED'); } - // この操作を行うのが投稿者とは限らない(例えばモデレーター)ため - await deleteNote(await Users.findOneByOrFail({ id: note.userId }), note); + // Here, we do not provide the current user because it may be an admin/moderator. + await deleteNotes([note]); }); diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts index a40b5bd76..909835f5f 100644 --- a/packages/backend/src/server/api/endpoints/notes/unrenote.ts +++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts @@ -1,4 +1,4 @@ -import deleteNote from '@/services/note/delete.js'; +import { deleteNotes } from '@/services/note/delete.js'; import { Notes, Users } from '@/models/index.js'; import { SECOND, HOUR } from '@/const.js'; import define from '@/server/api/define.js'; @@ -48,7 +48,7 @@ export default define(meta, paramDef, async (ps, user) => { renoteId: note.id, }); - for (const note of renotes) { - deleteNote(await Users.findOneByOrFail({ id: user.id }), note); - } + if (renotes.length === 0) return; + + await deleteNotes(renotes, user); }); diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts index 7636ab0ba..4af16383b 100644 --- a/packages/backend/src/server/api/error.ts +++ b/packages/backend/src/server/api/error.ts @@ -340,6 +340,10 @@ export const errors: Record message: 'You are not following that user.', httpStatusCode: 409, }, + NOT_FOLLOWED: { + message: 'You are not followed by that user.', + httpStatusCode: 409, + }, NOT_LIKED: { message: 'You have not liked that page.', httpStatusCode: 409, diff --git a/packages/backend/src/services/note/delete.ts b/packages/backend/src/services/note/delete.ts index 1df08d5ea..595c28ace 100644 --- a/packages/backend/src/services/note/delete.ts +++ b/packages/backend/src/services/note/delete.ts @@ -14,14 +14,88 @@ import { notesChart, perUserNotesChart, instanceChart } from '@/services/chart/i import { DeliverManager } from '@/remote/activitypub/deliver-manager.js'; import { countSameRenotes } from '@/misc/count-same-renotes.js'; import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; -import { deliverToRelays } from '../relay.js'; +import { deliverMultipleToRelays } from '../relay.js'; /** - * Delete your note. - * @param user author - * @param note note to be deleted + * Delete several notes of the same user. + * @param notes Array of notes to be deleted. + * @param user Author of the notes. Will be fetched if not provided. */ -export default async function(user: { id: User['id']; uri: User['uri']; host: User['host']; }, note: Note, quiet = false): Promise { +export async function deleteNotes(notes: Note[], user?: User): Promise { + if (notes.length === 0) return; + + const fetchedUser = user ?? await Users.findOneByOrFail({ id: notes[0].userId }); + + const cascadingNotes = await Promise.all( + notes.map(note => findCascadingNotes(note)) + ).then(res => res.flat()); + + // perform side effects for notes and cascaded notes + await Promise.all( + notes.concat(cascadingNotes) + .map(note => deletionSideEffects(note, fetchedUser)) + ); + + // Compute delivery content for later. + // It is important that this is done before deleting notes from + // the database since we may need some information from parent + // notes that cause this one to be cascade-deleted. + let content = await Promise.all( + notes.concat(cascadingNotes) + // only deliver for local notes that are not local-only + .filter(note => note.userHost == null && !note.localOnly) + .map(async note => { + let renote: Note | null = null; + + // if the deleted note is a renote + if (foundkey.entities.isPureRenote(note)) { + renote = await Notes.findOneBy({ id: note.renoteId }); + } + + return renderActivity(renote + ? renderUndo(renderAnnounce(renote.uri || `${config.url}/notes/${renote.id}`, note), fetchedUser) + : renderDelete(renderTombstone(`${config.url}/notes/${note.id}`), fetchedUser)); + }) + ); + + // Compute addressing information. + // Since we do not send any actual content, we send all note deletions to everyone. + const manager = new DeliverManager(fetchedUser, content); + manager.addFollowersRecipe(); + manager.addEveryone(); + // Check mentioned users, since not all may have a shared inbox. + await Promise.all( + notes.concat(cascadingNotes) + .map(note => getMentionedRemoteUsers(note)) + ) + .then(remoteUsers => { + remoteUsers.flat() + .forEach(remoteUser => manager.addDirectRecipe(remoteUser)) + }); + + // Actually delete notes from the database. + // It is important that this is done before delivering the activities. + // Otherwise there might be a race condition where we tell someone + // the note exists and they can successfully fetch it. + await Notes.delete({ + id: In(notes.map(x => x.id)), + userId: fetchedUser.id, + }); + + // deliver the previously computed content + await Promise.all([ + manager.execute(), + deliverMultipleToRelays(user, content), + ]); +} + +/** + * Perform side effects of deletion, such as updating statistics. + * Does not actually delete the note itself. + * @param note The soon to be deleted note. + * @param user The author of said note. + */ +async function deletionSideEffects(note: Note, user: User): Promise { const deletedAt = new Date(); // If this is the only renote of this note by this user @@ -34,48 +108,18 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us await Notes.decrement({ id: note.replyId }, 'repliesCount', 1); } - if (!quiet) { - publishNoteStream(note.id, 'deleted', { deletedAt }); + publishNoteStream(note.id, 'deleted', { deletedAt }); - // deliver delete activity of note itself for local posts - if (Users.isLocalUser(user) && !note.localOnly) { - let renote: Note | null = null; + // update statistics + notesChart.update(note, false); + perUserNotesChart.update(user, note, false); - // if deleted note is renote - if (foundkey.entities.isPureRenote(note)) { - renote = await Notes.findOneBy({ id: note.renoteId }); - } - - const content = renderActivity(renote - ? renderUndo(renderAnnounce(renote.uri || `${config.url}/notes/${renote.id}`, note), user) - : renderDelete(renderTombstone(`${config.url}/notes/${note.id}`), user)); - - deliverToConcerned(user, note, content); - } - - // also deliver delete activity to cascaded notes - const cascadingNotes = await findCascadingNotes(note); - for (const cascadingNote of cascadingNotes) { - const content = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${cascadingNote.id}`), cascadingNote.user)); - deliverToConcerned(cascadingNote.user, cascadingNote, content); - } - - // update statistics - notesChart.update(note, false); - perUserNotesChart.update(user, note, false); - - if (Users.isRemoteUser(user)) { - registerOrFetchInstanceDoc(user.host).then(i => { - Instances.decrement({ id: i.id }, 'notesCount', 1); - instanceChart.updateNote(i.host, note, false); - }); - } + if (Users.isRemoteUser(user)) { + registerOrFetchInstanceDoc(user.host).then(i => { + Instances.decrement({ id: i.id }, 'notesCount', 1); + instanceChart.updateNote(i.host, note, false); + }); } - - await Notes.delete({ - id: note.id, - userId: user.id, - }); } /** @@ -143,23 +187,3 @@ async function getMentionedRemoteUsers(note: Note): Promise { }) as IRemoteUser[]; } -async function deliverToConcerned(user: { id: ILocalUser['id']; host: null; }, note: Note, content: any): Promise { - const manager = new DeliverManager(user, content); - - const remoteUsers = await getMentionedRemoteUsers(note); - for (const remoteUser of remoteUsers) { - manager.addDirectRecipe(remoteUser); - } - - if (['public', 'home', 'followers'].includes(note.visibility)) { - manager.addFollowersRecipe(); - } - - if (['public', 'home'].includes(note.visibility)) { - manager.addEveryone(); - } - - await manager.execute(); - - deliverToRelays(user, content); -} diff --git a/packages/backend/src/services/relay.ts b/packages/backend/src/services/relay.ts index b60bbd425..21b869a82 100644 --- a/packages/backend/src/services/relay.ts +++ b/packages/backend/src/services/relay.ts @@ -105,3 +105,21 @@ export async function deliverToRelays(user: { id: User['id']; host: null; }, act deliver(user, signed, relay.inbox); } } + +export async function deliverMultipleToRelays(user: User, activities: any[]): Promise { + if (activities.length === 0) return; + + const relays = await relaysCache.fetch(''); + if (relays == null || relays.length === 0) return; + + const content = await Promise.all(activities.map(activity => { + const copy = structuredClone(activity); + if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; + + return attachLdSignature(copy, user); + })); + + for (const relay of relays) { + deliver(user, content, relay.inbox); + } +} diff --git a/packages/client/assets/about-icon.png b/packages/client/assets/about-icon.png index afc1f0c72..24f65815c 100644 Binary files a/packages/client/assets/about-icon.png and b/packages/client/assets/about-icon.png differ diff --git a/packages/client/assets/fedi.jpg b/packages/client/assets/fedi.jpg deleted file mode 100644 index cbf3748eb..000000000 Binary files a/packages/client/assets/fedi.jpg and /dev/null differ diff --git a/packages/client/assets/misskey.svg b/packages/client/assets/misskey.svg deleted file mode 100644 index 3fcb2d3ec..000000000 Binary files a/packages/client/assets/misskey.svg and /dev/null differ diff --git a/packages/client/src/components/drive.file.vue b/packages/client/src/components/drive.file.vue index 6e3d38612..f6713947e 100644 --- a/packages/client/src/components/drive.file.vue +++ b/packages/client/src/components/drive.file.vue @@ -52,7 +52,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'chosen', r: foundkey.entities.DriveFile): void; + (ev: 'chosen', r: foundkey.entities.DriveFile, extendSelection: boolean): void; (ev: 'dragstart'): void; (ev: 'dragend'): void; }>(); @@ -95,9 +95,7 @@ function getMenu(): MenuItem[] { function onClick(ev: MouseEvent): void { if (props.selectMode) { - emit('chosen', props.file); - } else { - os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); + emit('chosen', props.file, ev.ctrlKey); } } @@ -330,7 +328,7 @@ async function deleteFile(): Promise { overflow: hidden; > .ext { - opacity: 0.5; + opacity: 0.7; } } } diff --git a/packages/client/src/components/drive.folder.vue b/packages/client/src/components/drive.folder.vue index 028dc7f2b..c613571f5 100644 --- a/packages/client/src/components/drive.folder.vue +++ b/packages/client/src/components/drive.folder.vue @@ -1,10 +1,10 @@ @@ -44,7 +45,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'chosen', v: foundkey.entities.DriveFolder): void; + (ev: 'chosen', v: foundkey.entities.DriveFolder, extendSelection: boolean): void; (ev: 'move', v: foundkey.entities.DriveFolder): void; (ev: 'upload', file: File, folder: foundkey.entities.DriveFolder); (ev: 'removeFile', v: foundkey.entities.DriveFile['id']): void; @@ -59,20 +60,10 @@ const isDragging = ref(false); const title = computed(() => props.folder.name); -function checkboxClicked() { - emit('chosen', props.folder); -} - -function onClick() { - emit('move', props.folder); -} - -function onMouseover() { - hover.value = true; -} - -function onMouseout() { - hover.value = false; +function selected(ev: MouseEvent) { + if (props.selectMode) { + emit('chosen', props.folder, ev.ctrlKey); + } } function onDragover(ev: DragEvent) { @@ -260,30 +251,34 @@ function onContextmenu(ev: MouseEvent) { .rghtznwe { position: relative; padding: 8px; - height: 64px; - background: var(--driveFolderBg); - border-radius: 4px; + min-height: 180px; + border-radius: 8px; &, * { cursor: pointer; } - *:not(.checkbox) { - pointer-events: none; - } + > .thumbnail { + width: 110px; + height: 110px; + margin: auto; - > .checkbox { - position: absolute; - bottom: 8px; - right: 8px; - width: 16px; - height: 16px; - background: #fff; - border: solid 1px #000; + /* same style as drive-file-thumbnail.vue */ + position: relative; + display: flex; + background: var(--panel); + border-radius: 8px; + overflow: clip; - &.checked { - background: var(--accent); + > i { + pointer-events: none; + margin: auto; + font-size: 33px; + color: #777; } + + &:not(:hover) > i.hover, + &:hover > i:not(.hover) { display: none; } } &.draghover { @@ -300,23 +295,37 @@ function onContextmenu(ev: MouseEvent) { } } - > .name { - margin: 0; - font-size: 0.9em; - color: var(--desktopDriveFolderFg); + &.isSelected { + background: var(--accent); - > i { - margin-right: 4px; - margin-left: 2px; - text-align: left; + &:hover { + background: var(--accentLighten); } + + > .name { + color: #fff; + } + + > .thumbnail { + color: #fff; + } + } + + > .name { + display: block; + margin: 4px 0 0 0; + font-size: 0.8em; + text-align: center; + word-break: break-all; + color: var(--fg); + overflow: hidden; } > .upload { margin: 4px 4px; font-size: 0.8em; - text-align: right; - color: var(--desktopDriveFolderFg); + text-align: center; + opacity: 0.7; } } diff --git a/packages/client/src/components/drive.vue b/packages/client/src/components/drive.vue index c01c328af..d3d6a4527 100644 --- a/packages/client/src/components/drive.vue +++ b/packages/client/src/components/drive.vue @@ -35,31 +35,37 @@ @drop.prevent.stop="onDrop" @contextmenu.stop="onContextmenu" > -
- - + + - +
@@ -129,7 +98,6 @@ import { uploadFile, uploads } from '@/scripts/upload'; const props = withDefaults(defineProps<{ initialFolder?: foundkey.entities.DriveFolder; - type?: string; multiple?: boolean; select?: 'file' | 'folder' | null; }>(), { @@ -139,19 +107,13 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'selected', v: foundkey.entities.DriveFile | foundkey.entities.DriveFolder): void; - (ev: 'change-selection', v: foundkey.entities.DriveFile[] | foundkey.entities.DriveFolder[]): void; + (ev: 'change-selection', v: Array): void; (ev: 'move-root'): void; (ev: 'cd', v: foundkey.entities.DriveFolder | null): void; (ev: 'open-folder', v: foundkey.entities.DriveFolder): void; }>(); -let foldersPaginationElem = $ref>(); -let filesPaginationElem = $ref>(); - -let foldersLoading = $ref(true); -let filesLoading = $ref(true); -const empty = $computed(() => !foldersLoading && !filesLoading - && foldersPaginationElem?.items.length === 0 && filesPaginationElem?.items.length === 0); +let paginationElem = $ref>(); let fileInput = $ref(); @@ -160,8 +122,7 @@ const connection = stream.useChannel('drive'); let folder = $ref(null); let hierarchyFolders = $ref([]); -let selectedFiles = $ref([]); -let selectedFolders = $ref([]); +let selected = $ref>([]); let keepOriginal = $ref(defaultStore.state.keepOriginalUploading); // ドロップされようとしているか @@ -171,7 +132,14 @@ let draghover = $ref(false); // (自分自身の階層にドロップできないようにするためのフラグ) let isDragSource = $ref(false); -watch($$(folder), () => emit('cd', folder)); +watch($$(folder), () => { + emit('cd', folder) + if (props.select === 'folder') { + // convenience: entering a folder selects it + selected = [folder]; + emit('change-selection', selected); + } +}); function onStreamDriveFileCreated(file: foundkey.entities.DriveFile) { addFile(file, true); @@ -210,9 +178,8 @@ function onStreamDriveFolderDeleted(folderId: string) { function onDragover(ev: DragEvent): any { if (!ev.dataTransfer) return; - // ドラッグ元が自分自身の所有するアイテムだったら if (isDragSource) { - // 自分自身にはドロップさせない + // We are the drag source, do not allow to drop. ev.dataTransfer.dropEffect = 'none'; return; } @@ -257,17 +224,16 @@ function onDrop(ev: DragEvent): any { if (!ev.dataTransfer) return; - // ドロップされてきたものがファイルだったら + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (ev.dataTransfer.files.length > 0) { + // dropping operating system files for (const file of Array.from(ev.dataTransfer.files)) { upload(file, folder); } - return; - } - - //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { + } else if (driveFile != null && driveFile !== '') { + // dropping drive files const file = JSON.parse(driveFile); // cannot move file within parent folder @@ -278,18 +244,14 @@ function onDrop(ev: DragEvent): any { fileId: file.id, folderId: folder?.id ?? null, }); - } - //#endregion - - //#region ドライブのフォルダ - const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder !== '') { + } else if (driveFolder != null && driveFolder !== '') { + // dropping drive folders const droppedFolder = JSON.parse(driveFolder); // cannot move folder into itself if (droppedFolder.id === folder?.id) return false; // cannot move folder within parent folder - if (foldersPaginationElem.items.some(f => f.id === droppedFolder.id)) return false; + if (folder.id === droppedFolder.parentId) return false; removeFolder(droppedFolder.id); os.api('drive/folders/update', { @@ -311,7 +273,6 @@ function onDrop(ev: DragEvent): any { } }); } - //#endregion } function selectLocalFile() { @@ -346,8 +307,6 @@ function createFolder() { os.api('drive/folders/create', { name, parentId: folder?.id ?? undefined, - }).then(createdFolder => { - addFolder(createdFolder, true); }); }); } @@ -404,49 +363,52 @@ function upload(file: File, folderToUpload?: foundkey.entities.DriveFolder | nul uploadFile(file, folderToUpload?.id ?? null, undefined, keepOriginal); } -function chooseFile(file: foundkey.entities.DriveFile) { - const isAlreadySelected = selectedFiles.some(f => f.id === file.id); - if (props.multiple) { - if (isAlreadySelected) { - selectedFiles = selectedFiles.filter(f => f.id !== file.id); - } else { - selectedFiles.push(file); - } - emit('change-selection', selectedFiles); - } else { - if (isAlreadySelected) { - emit('selected', file); - } else { - selectedFiles = [file]; - emit('change-selection', [file]); - } - } -} +function choose(choice: foundkey.entities.DriveFile | foundkey.entities.DriveFolder, extendSelection: boolean) { + const alreadySelected = selected.some(f => f.id === file.id); -function chooseFolder(folderToChoose: foundkey.entities.DriveFolder) { - const isAlreadySelected = selectedFolders.some(f => f.id === folderToChoose.id); - if (props.multiple) { - if (isAlreadySelected) { - selectedFolders = selectedFolders.filter(f => f.id !== folderToChoose.id); + const action = (() => { + if (props.select != null) { + // file picker mode, extendSelection is disregarded + if (props.multiple && alreadySelected) { + return 'remove'; + } else if (props.multiple) { + return 'add'; + } else if (!props.multiple && alreadySelected) { + return 'emit'; + } else { + return 'set'; + } } else { - selectedFolders.push(folderToChoose); - } - emit('change-selection', selectedFolders); - } else { - if (isAlreadySelected) { - emit('selected', folderToChoose); - } else { - selectedFolders = [folderToChoose]; - emit('change-selection', [folderToChoose]); + // explorer mode, props.multiple is disregarded + if (extendSelection && alreadySelected) { + return 'remove'; + } else if (extendSelection) { + return 'add'; + } else if (!alreadySelected) { + return 'set'; + } + // already selected && ! extend selection is a noop } + })(); + + switch (action) { + case 'emit': + emit('selected', choice); + return; // don't emit the change-selection event + case 'add': + selected.push(choice); + break; + case 'set': + selected = [choice]; + break; + case 'remove': + selected = selected.filter(f => f.id !== choice.id); + break; } + emit('change-selection', selected); } function move(target?: string | foundkey.entities.DriveFolder) { - // reset loading state - foldersLoading = true; - filesLoading = true; - if (!target) { goRoot(); return; @@ -475,13 +437,13 @@ function addFolder(folderToAdd: foundkey.entities.DriveFolder, unshift = false) const current = folder?.id ?? null; if (current !== folderToAdd.parentId) return; - const exist = foldersPaginationElem.items.some(f => f.id === folderToAdd.id); + const exist = paginationElem.items.some(f => f.id === folderToAdd.id); if (exist) { - foldersPaginationElem.updateItem(folderToAdd.id, () => folderToAdd); + paginationElem.updateItem(folderToAdd.id, () => folderToAdd); } else if (unshift) { - foldersPaginationElem.prepend(folderToAdd); + paginationElem.prepend(folderToAdd); } else { - foldersPaginationElem.append(folderToAdd); + paginationElem.append(folderToAdd); } } @@ -489,24 +451,24 @@ function addFile(fileToAdd: foundkey.entities.DriveFile, unshift = false) { const current = folder?.id ?? null; if (current !== fileToAdd.folderId) return; - const exist = filesPaginationElem.items.some(f => f.id === fileToAdd.id); + const exist = paginationElem.items.some(f => f.id === fileToAdd.id); if (exist) { - filesPaginationElem.updateItem(fileToAdd.id, () => fileToAdd); + paginationElem.updateItem(fileToAdd.id, () => fileToAdd); } else if (unshift) { - filesPaginationElem.prepend(fileToAdd); + paginationElem.prepend(fileToAdd); } else { - filesPaginationElem.append(fileToAdd); + paginationElem.append(fileToAdd); } } function removeFolder(folderToRemove: foundkey.entities.DriveFolder | string): void { const folderIdToRemove = typeof folderToRemove === 'object' ? folderToRemove.id : folderToRemove; - foldersPaginationElem.removeItem(item => item.id === folderIdToRemove); + paginationElem.removeItem(item => item.id === folderIdToRemove); } function removeFile(fileToRemove: foundkey.entities.DriveFile | string): void { const fileIdToRemove = typeof fileToRemove === 'object' ? fileToRemove.id : fileToRemove; - filesPaginationElem.removeItem(item => item.id === fileIdToRemove); + paginationElem.removeItem(item => item.id === fileIdToRemove); } function goRoot() { @@ -518,23 +480,14 @@ function goRoot() { emit('move-root'); } -const foldersPagination = { - endpoint: 'drive/folders' as const, +const pagination = { + endpoint: 'drive/show' as const, limit: 30, params: computed(() => ({ folderId: folder?.id ?? null, })), }; -const filesPagination = { - endpoint: 'drive/files' as const, - limit: 30, - params: computed(() => ({ - folderId: folder?.id ?? null, - type: props.type, - })), -}; - function getMenu() { return [{ type: 'switch', @@ -678,37 +631,17 @@ onBeforeUnmount(() => { } > .contents { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: .5em; + } - > .folders, - > .files { - display: flex; - flex-wrap: wrap; - - > .folder, - > .file { - flex-grow: 1; - width: 128px; - margin: 4px; - box-sizing: border-box; - } - - > .padding { - flex-grow: 1; - pointer-events: none; - width: 128px + 8px; - } - } - - > .empty { - padding: 16px; - text-align: center; - pointer-events: none; - opacity: 0.5; - - > p { - margin: 0; - } - } + > .empty { + padding: 16px; + text-align: center; + pointer-events: none; + opacity: 0.5; + margin: 0; } } diff --git a/packages/client/src/components/follow-button.vue b/packages/client/src/components/follow-button.vue index 938e73e27..e8bb264c6 100644 --- a/packages/client/src/components/follow-button.vue +++ b/packages/client/src/components/follow-button.vue @@ -26,6 +26,7 @@ import * as foundkey from 'foundkey-js'; import * as os from '@/os'; import { stream } from '@/stream'; import { i18n } from '@/i18n'; +import { pleaseLoginOrRemote, urlForUser } from '@/scripts/please-login'; const props = withDefaults(defineProps<{ user: foundkey.entities.UserDetailed, @@ -59,6 +60,8 @@ async function onClick() { wait = true; try { + pleaseLoginOrRemote(urlForUser(props.user)); + if (isFollowing) { const { canceled } = await os.confirm({ type: 'warning', diff --git a/packages/client/src/components/key-value.vue b/packages/client/src/components/key-value.vue index 2b9c735dd..079c7fbb8 100644 --- a/packages/client/src/components/key-value.vue +++ b/packages/client/src/components/key-value.vue @@ -5,6 +5,7 @@
+
diff --git a/packages/client/src/components/launch-pad.vue b/packages/client/src/components/launch-pad.vue index 2733903cb..5723a0151 100644 --- a/packages/client/src/components/launch-pad.vue +++ b/packages/client/src/components/launch-pad.vue @@ -126,6 +126,7 @@ function help(ev: MouseEvent) { flex-direction: column; align-items: center; justify-content: center; + text-align: center; vertical-align: bottom; height: 100px; border-radius: 10px; diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue index 3549ef953..83d8b02e2 100644 --- a/packages/client/src/components/note-detailed.vue +++ b/packages/client/src/components/note-detailed.vue @@ -126,7 +126,7 @@ import XRenoteButton from './renote-button.vue'; import MkUrlPreview from '@/components/url-preview.vue'; import MkInstanceTicker from '@/components/instance-ticker.vue'; import MkVisibility from '@/components/visibility.vue'; -import { pleaseLogin } from '@/scripts/please-login'; +import { pleaseLoginOrRemote, urlForNote } from '@/scripts/please-login'; import { checkWordMute } from '@/scripts/check-word-mute'; import { userPage } from '@/filters/user'; import { notePage } from '@/filters/note'; @@ -195,7 +195,8 @@ useNoteCapture({ }); function reply(viaKeyboard = false): void { - pleaseLogin(); + pleaseLoginOrRemote(urlForNote(appearNote)); + os.post({ reply: appearNote, animation: !viaKeyboard, @@ -205,7 +206,8 @@ function reply(viaKeyboard = false): void { } function react(): void { - pleaseLogin(); + pleaseLoginOrRemote(urlForNote(appearNote)); + blur(); reactionPicker.show(reactButton.value, reaction => { os.api('notes/reactions/create', { diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue index b0938b3c0..8324f083a 100644 --- a/packages/client/src/components/note.vue +++ b/packages/client/src/components/note.vue @@ -115,7 +115,7 @@ import XRenoteButton from './renote-button.vue'; import MkUrlPreview from '@/components/url-preview.vue'; import MkInstanceTicker from '@/components/instance-ticker.vue'; import MkVisibility from '@/components/visibility.vue'; -import { pleaseLogin } from '@/scripts/please-login'; +import { pleaseLoginOrRemote, urlForNote } from '@/scripts/please-login'; import { focusPrev, focusNext } from '@/scripts/focus'; import { checkWordMute } from '@/scripts/check-word-mute'; import { userPage } from '@/filters/user'; @@ -188,7 +188,8 @@ useNoteCapture({ }); function reply(viaKeyboard = false): void { - pleaseLogin(); + pleaseLoginOrRemote(urlForNote(appearNote)); + os.post({ reply: appearNote, animation: !viaKeyboard, @@ -198,7 +199,8 @@ function reply(viaKeyboard = false): void { } function react(): void { - pleaseLogin(); + pleaseLoginOrRemote(urlForNote(appearNote)); + blur(); reactionPicker.show(reactButton.value, reaction => { os.api('notes/reactions/create', { diff --git a/packages/client/src/components/poll.vue b/packages/client/src/components/poll.vue index 92f0961c9..db1363d28 100644 --- a/packages/client/src/components/poll.vue +++ b/packages/client/src/components/poll.vue @@ -25,7 +25,7 @@ import { computed, ref } from 'vue'; import * as foundkey from 'foundkey-js'; import { sum } from '@/scripts/array'; -import { pleaseLogin } from '@/scripts/please-login'; +import { pleaseLoginOrRemote, urlForNote } from '@/scripts/please-login'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { useInterval } from '@/scripts/use-interval'; @@ -68,7 +68,7 @@ if (props.note.poll.expiresAt) { } const vote = async (id) => { - pleaseLogin(); + pleaseLoginOrRemote(urlForNote(props.note)); if (props.readOnly || closed.value || isVoted.value) return; diff --git a/packages/client/src/components/remote-interact.vue b/packages/client/src/components/remote-interact.vue new file mode 100644 index 000000000..3ebdc5e0f --- /dev/null +++ b/packages/client/src/components/remote-interact.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/packages/client/src/components/renote-button.vue b/packages/client/src/components/renote-button.vue index 9e4dbb3e6..4f84b783a 100644 --- a/packages/client/src/components/renote-button.vue +++ b/packages/client/src/components/renote-button.vue @@ -17,7 +17,7 @@ import { computed, ref } from 'vue'; import { Note } from 'foundkey-js/built/entities'; import XDetails from '@/components/users-tooltip.vue'; -import { pleaseLogin } from '@/scripts/please-login'; +import { pleaseLoginOrRemote, urlForNote } from '@/scripts/please-login'; import * as os from '@/os'; import { $i } from '@/account'; import { useTooltip } from '@/scripts/use-tooltip'; @@ -51,7 +51,8 @@ useTooltip(buttonRef, async (showing) => { }); function renote(viaKeyboard = false): void { - pleaseLogin(); + pleaseLoginOrRemote(urlForNote(props.note)); + os.popupMenu([{ text: i18n.ts.renote, icon: 'fas fa-retweet', diff --git a/packages/client/src/components/wavesurfer.vue b/packages/client/src/components/wavesurfer.vue index 38883e83c..9d9fb8f0f 100644 --- a/packages/client/src/components/wavesurfer.vue +++ b/packages/client/src/components/wavesurfer.vue @@ -14,7 +14,7 @@ {{ formatTime(duration) }} - + diff --git a/packages/client/src/nirax.ts b/packages/client/src/nirax.ts index 51f1dc20a..795e27eb5 100644 --- a/packages/client/src/nirax.ts +++ b/packages/client/src/nirax.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'eventemitter3'; import { Component, shallowRef, ShallowRef } from 'vue'; -import { pleaseLogin } from '@/scripts/please-login'; +import { pleaseLoginOrPage } from '@/scripts/please-login'; import { safeURIDecode } from '@/scripts/safe-uri-decode'; type RouteDef = { @@ -174,7 +174,7 @@ export class Router extends EventEmitter<{ } if (res.route.loginRequired) { - pleaseLogin('/'); + pleaseLoginOrPage('/'); } const isSamePath = beforePath === path; diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue index 1d74216ea..4777ae56e 100644 --- a/packages/client/src/pages/settings/theme.vue +++ b/packages/client/src/pages/settings/theme.vue @@ -51,7 +51,7 @@
- {{ i18n.ts._theme.manage }} + {{ i18n.ts._theme.manage }} {{ i18n.ts._theme.explore }} {{ i18n.ts._theme.install }} {{ i18n.ts._theme.make }} @@ -71,7 +71,7 @@ import FormSelect from '@/components/form/select.vue'; import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; import MkButton from '@/components/ui/button.vue'; -import { getBuiltinThemesRef } from '@/scripts/theme'; +import { getBuiltinThemes } from '@/scripts/theme'; import { selectFile } from '@/scripts/select-file'; import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; import { ColdDeviceStorage , defaultStore } from '@/store'; @@ -81,38 +81,38 @@ import { uniqueBy } from '@/scripts/array'; import { fetchThemes, getThemes } from '@/theme-store'; import { definePageMetadata } from '@/scripts/page-metadata'; -const installedThemes = ref(getThemes()); -const builtinThemes = getBuiltinThemesRef(); +const [installedThemes, builtinThemes] = await Promise.all([fetchThemes(), getBuiltinThemes()]); const instanceThemes = []; if (instance.defaultLightTheme != null) instanceThemes.push(JSON5.parse(instance.defaultLightTheme)); if (instance.defaultDarkTheme != null) instanceThemes.push(JSON5.parse(instance.defaultDarkTheme)); -const themes = computed(() => uniqueBy([ ...instanceThemes, ...builtinThemes.value, ...installedThemes.value ], theme => theme.id)); -const darkThemes = computed(() => themes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); -const lightThemes = computed(() => themes.value.filter(t => t.base === 'light' || t.kind === 'light')); -const darkTheme = ColdDeviceStorage.ref('darkTheme'); +const themes = uniqueBy([...installedThemes, ...instanceThemes, ...builtinThemes], theme => theme.id); +const darkThemes = themes.filter(t => t.base === 'dark' || t.kind === 'dark'); +const lightThemes = themes.filter(t => t.base === 'light' || t.kind === 'light'); +let darkTheme = $ref(ColdDeviceStorage.get('darkTheme')); const darkThemeId = computed({ get() { - return darkTheme.value.id; + return darkTheme.id; }, set(id) { - ColdDeviceStorage.set('darkTheme', themes.value.find(x => x.id === id)); + darkTheme = themes.find(x => x.id === id); + ColdDeviceStorage.set('darkTheme', darkTheme); }, }); -const lightTheme = ColdDeviceStorage.ref('lightTheme'); +let lightTheme = $ref(ColdDeviceStorage.get('lightTheme')); const lightThemeId = computed({ get() { - return lightTheme.value.id; + return lightTheme.id; }, set(id) { - ColdDeviceStorage.set('lightTheme', themes.value.find(x => x.id === id)); + lightTheme = themes.find(x => x.id === id); + ColdDeviceStorage.set('lightTheme', lightTheme); }, }); const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode')); const wallpaper = ref(localStorage.getItem('wallpaper')); -const themesCount = installedThemes.value.length; watch(syncDeviceDarkMode, () => { if (syncDeviceDarkMode.value) { @@ -129,16 +129,6 @@ watch(wallpaper, () => { location.reload(); }); -onActivated(() => { - fetchThemes().then(() => { - installedThemes.value = getThemes(); - }); -}); - -fetchThemes().then(() => { - installedThemes.value = getThemes(); -}); - function setWallpaper(event) { selectFile(event.currentTarget ?? event.target, null).then(file => { wallpaper.value = file.url; diff --git a/packages/client/src/pages/user/home.vue b/packages/client/src/pages/user/home.vue index cb4d0efce..2fcee6987 100644 --- a/packages/client/src/pages/user/home.vue +++ b/packages/client/src/pages/user/home.vue @@ -23,10 +23,10 @@
- {{ i18n.ts.followsYou }} -
+ {{ i18n.ts.followsYou }} +
- +
diff --git a/packages/client/src/pages/user/index.vue b/packages/client/src/pages/user/index.vue index 24c88e75d..1db70550d 100644 --- a/packages/client/src/pages/user/index.vue +++ b/packages/client/src/pages/user/index.vue @@ -20,7 +20,6 @@ import { defineAsyncComponent, computed, watch } from 'vue'; import * as Acct from 'foundkey-js/built/acct'; import * as foundkey from 'foundkey-js'; -import { getUserMenu } from '@/scripts/get-user-menu'; import { acct as getAcct } from '@/filters/user'; import * as os from '@/os'; import { useRouter } from '@/router'; @@ -60,10 +59,6 @@ watch(() => props.acct, fetchUser, { immediate: true, }); -function menu(ev) { - os.popupMenu(getUserMenu(user), ev.currentTarget ?? ev.target); -} - const headerTabs = $computed(() => [{ key: 'home', title: i18n.ts.overview, diff --git a/packages/client/src/scripts/please-login.ts b/packages/client/src/scripts/please-login.ts index 52862427b..d31aea40b 100644 --- a/packages/client/src/scripts/please-login.ts +++ b/packages/client/src/scripts/please-login.ts @@ -2,8 +2,10 @@ import { defineAsyncComponent } from 'vue'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import { popup } from '@/os'; +import { url } from '@/config'; +import { entities } from 'foundkey-js'; -export function pleaseLogin(path?: string) { +export function pleaseLoginOrPage(path?: string) { if ($i) return; popup(defineAsyncComponent(() => import('@/components/signin-dialog.vue')), { @@ -19,3 +21,25 @@ export function pleaseLogin(path?: string) { if (!path) throw new Error('signin required'); } + +export function pleaseLoginOrRemote(remoteUrl: string) { + if ($i) return; + + popup(defineAsyncComponent(() => import('@/components/remote-interact.vue')), { + remoteUrl, + }, {}, 'closed'); + + throw new Error('signin required'); +} + +export function urlForNote(note: entities.Note): string { + return note.url + ?? note.uri + ?? `${url}/notes/${note.id}`; +} + +export function urlForUser(user: entities.User): string { + return user.url + ?? user.uri + ?? `${url}/users/${user.id}`; +} diff --git a/packages/client/src/theme-store.ts b/packages/client/src/theme-store.ts index b9ef11229..02df88582 100644 --- a/packages/client/src/theme-store.ts +++ b/packages/client/src/theme-store.ts @@ -8,21 +8,22 @@ export function getThemes(): Theme[] { return JSON.parse(localStorage.getItem(lsCacheKey) || '[]'); } -export async function fetchThemes(): Promise { - if ($i == null) return; +export async function fetchThemes(): Promise { + if ($i == null) return []; try { const themes = await api('i/registry/get', { scope: ['client'], key: 'themes' }); localStorage.setItem(lsCacheKey, JSON.stringify(themes)); + return themes; } catch (err) { - if (err.code === 'NO_SUCH_KEY') return; + if (err.code === 'NO_SUCH_KEY') return []; throw err; } } export async function addTheme(theme: Theme): Promise { - await fetchThemes(); - const themes = getThemes().concat(theme); + const themes = await fetchThemes() + .then(themes => themes.concat(theme)); await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); localStorage.setItem(lsCacheKey, JSON.stringify(themes)); } diff --git a/packages/client/src/themes/_dark.json5 b/packages/client/src/themes/_dark.json5 index cda66a508..a4c51819b 100644 --- a/packages/client/src/themes/_dark.json5 +++ b/packages/client/src/themes/_dark.json5 @@ -64,7 +64,6 @@ inputBorder: 'rgba(255, 255, 255, 0.1)', inputBorderHover: 'rgba(255, 255, 255, 0.2)', listItemHoverBg: 'rgba(255, 255, 255, 0.03)', - driveFolderBg: ':alpha<0.3<@accent', wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', badge: '#31b1ce', messageBg: '@bg', diff --git a/packages/client/src/themes/_light.json5 b/packages/client/src/themes/_light.json5 index 50c5388e0..1aae2b8cf 100644 --- a/packages/client/src/themes/_light.json5 +++ b/packages/client/src/themes/_light.json5 @@ -64,7 +64,6 @@ inputBorder: 'rgba(0, 0, 0, 0.1)', inputBorderHover: 'rgba(0, 0, 0, 0.2)', listItemHoverBg: 'rgba(0, 0, 0, 0.03)', - driveFolderBg: ':alpha<0.3<@accent', wallpaperOverlay: 'rgba(255, 255, 255, 0.5)', badge: '#31b1ce', messageBg: '@bg', diff --git a/packages/client/src/themes/d-astro.json5 b/packages/client/src/themes/d-astro.json5 index 7b9034020..873636981 100644 --- a/packages/client/src/themes/d-astro.json5 +++ b/packages/client/src/themes/d-astro.json5 @@ -46,7 +46,6 @@ buttonHoverBg: 'rgba(255, 255, 255, 0.1)', buttonGradateA: '@accent', buttonGradateB: ':hue<-20<@accent', - driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', diff --git a/packages/client/src/themes/d-pumpkin.json5 b/packages/client/src/themes/d-pumpkin.json5 index ff2bc9509..e466eb688 100644 --- a/packages/client/src/themes/d-pumpkin.json5 +++ b/packages/client/src/themes/d-pumpkin.json5 @@ -66,7 +66,6 @@ navIndicator: '@indicator', accentLighten: ':lighten<10<@accent', buttonHoverBg: 'rgba(255, 255, 255, 0.1)', - driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', fgTransparent: ':alpha<0.5<@fg', panelHeaderBg: ':lighten<3<@panel', diff --git a/packages/client/src/themes/l-vivid.json5 b/packages/client/src/themes/l-vivid.json5 index 66d97f3a0..ede4e0571 100644 --- a/packages/client/src/themes/l-vivid.json5 +++ b/packages/client/src/themes/l-vivid.json5 @@ -47,7 +47,6 @@ navIndicator: '@accent', accentLighten: ':lighten<10<@accent', buttonHoverBg: 'rgba(0, 0, 0, 0.1)', - driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':darken<3<@fg', fgTransparent: ':alpha<0.5<@fg', panelHeaderBg: ':lighten<3<@panel',