diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b7ef578c52..390bfc9f31 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -96,6 +96,9 @@ common: specified: "ダイレクト" specified-desc: "指定したユーザーにのみ公開" private: "非公開" + local-public: "公開(ローカルのみ)" + local-home: "ホーム(ローカルのみ)" + local-followers: "フォロワー(ローカルのみ)" note-placeholders: a: "今どうしてる?" @@ -471,6 +474,9 @@ common/views/components/visibility-chooser.vue: specified: "ダイレクト" specified-desc: "指定したユーザーにのみ公開" private: "非公開" + local-public: "公開(ローカルのみ)" + local-home: "ホーム(ローカルのみ)" + local-followers: "フォロワー(ローカルのみ)" common/views/components/trends.vue: count: "{}人が投稿" @@ -761,6 +767,7 @@ desktop/views/components/post-form.vue: create-poll: "アンケートを作成" text-remain: "残り{}文字" recent-tags: "最近" + local-only-message: "この投稿はローカルにのみ公開されます" click-to-tagging: "クリックでタグ付け" visibility: "公開範囲" geolocation-alert: "お使いの端末は位置情報に対応していません" diff --git a/src/client/app/common/views/components/note-header.vue b/src/client/app/common/views/components/note-header.vue index 2c7ae0194c..012b678ab3 100644 --- a/src/client/app/common/views/components/note-header.vue +++ b/src/client/app/common/views/components/note-header.vue @@ -19,6 +19,9 @@ <template v-if="note.visibility == 'specified'"><fa icon="envelope"/></template> <template v-if="note.visibility == 'private'"><fa icon="lock"/></template> </span> + <span class="localOnly" v-if="note.localOnly == true"> + <template><fa icon="heart"/></template> + </span> </div> </header> </template> @@ -115,4 +118,7 @@ export default Vue.extend({ > .visibility margin-left 8px + > .localOnly + margin-left 4px + </style> diff --git a/src/client/app/common/views/components/visibility-chooser.vue b/src/client/app/common/views/components/visibility-chooser.vue index 896be039b3..0335fba0ee 100644 --- a/src/client/app/common/views/components/visibility-chooser.vue +++ b/src/client/app/common/views/components/visibility-chooser.vue @@ -35,6 +35,24 @@ <span>{{ $t('private') }}</span> </div> </div> + <div @click="choose('local-public')" :class="{ active: v == 'local-public' }"> + <div><fa icon="globe"/></div> + <div> + <span>{{ $t('local-public') }}</span> + </div> + </div> + <div @click="choose('local-home')" :class="{ active: v == 'local-home' }"> + <div><fa icon="home"/></div> + <div> + <span>{{ $t('local-home') }}</span> + </div> + </div> + <div @click="choose('local-followers')" :class="{ active: v == 'local-followers' }"> + <div><fa icon="unlock"/></div> + <div> + <span>{{ $t('local-followers') }}</span> + </div> + </div> </div> </div> </template> diff --git a/src/client/app/desktop/views/components/note.vue b/src/client/app/desktop/views/components/note.vue index e2b67c150f..6bd4674269 100644 --- a/src/client/app/desktop/views/components/note.vue +++ b/src/client/app/desktop/views/components/note.vue @@ -20,6 +20,15 @@ <router-link class="name" :to="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</router-link> <span>{{ this.$t('reposted-by').substr(this.$t('reposted-by').indexOf('}') + 1) }}</span> <mk-time :time="note.createdAt"/> + <span class="visibility" v-if="note.visibility != 'public'"> + <template v-if="note.visibility == 'home'"><fa icon="home"/></template> + <template v-if="note.visibility == 'followers'"><fa icon="unlock"/></template> + <template v-if="note.visibility == 'specified'"><fa icon="envelope"/></template> + <template v-if="note.visibility == 'private'"><fa icon="lock"/></template> + </span> + <span class="localOnly" v-if="note.localOnly == true"> + <template><fa icon="heart"/></template> + </span> </div> <article> <mk-avatar class="avatar" :user="appearNote.user"/> @@ -199,9 +208,6 @@ export default Vue.extend({ > span flex-shrink 0 - &:last-of-type - margin-right 8px - .name overflow hidden flex-shrink 1 @@ -215,6 +221,18 @@ export default Vue.extend({ flex-shrink 0 font-size 0.9em + > .visibility + margin-left 8px + + [data-icon] + margin-right 0 + + > .localOnly + margin-left 4px + + [data-icon] + margin-right 0 + & + article padding-top 8px diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue index e05fab168c..02478b4eb3 100644 --- a/src/client/app/desktop/views/components/post-form.vue +++ b/src/client/app/desktop/views/components/post-form.vue @@ -14,6 +14,7 @@ <b>{{ $t('recent-tags') }}:</b> <a v-for="tag in recentHashtags.slice(0, 5)" @click="addTag(tag)" :title="$t('click-to-tagging')">#{{ tag }}</a> </div> + <div class="local-only" v-if="this.localOnly == true">{{ $t('local-only-message') }}</div> <input v-show="useCw" v-model="cw" :placeholder="$t('annotations')"> <div class="textarea"> <textarea :class="{ with: (files.length != 0 || poll) }" @@ -112,6 +113,7 @@ export default Vue.extend({ geo: null, visibility: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility, visibleUsers: [], + localOnly: false, autocomplete: null, draghover: false, recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), @@ -363,7 +365,14 @@ export default Vue.extend({ source: this.$refs.visibilityButton }); w.$once('chosen', v => { - this.visibility = v; + const m = v.match(/^local-(.+)/); + if (m) { + this.localOnly = true; + this.visibility = m[1]; + } else { + this.localOnly = false; + this.visibility = v; + } }); }, @@ -407,6 +416,7 @@ export default Vue.extend({ cw: this.useCw ? this.cw || '' : undefined, visibility: this.visibility, visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, + localOnly: this.localOnly, geo: this.geo ? { coordinates: [this.geo.longitude, this.geo.latitude], altitude: this.geo.altitude, @@ -640,6 +650,10 @@ export default Vue.extend({ margin-right 8px white-space nowrap + > .local-only + margin 0 0 8px 0 + color var(--primary) + > .mk-uploader margin 8px 0 0 0 padding 8px diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue index d42efbf344..38fab0e5a1 100644 --- a/src/client/app/mobile/views/components/note.vue +++ b/src/client/app/mobile/views/components/note.vue @@ -16,6 +16,15 @@ <router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link> <span>{{ this.$t('reposted-by').substr(this.$t('reposted-by').indexOf('}') + 1) }}</span> <mk-time :time="note.createdAt"/> + <span class="visibility" v-if="note.visibility != 'public'"> + <template v-if="note.visibility == 'home'"><fa icon="home"/></template> + <template v-if="note.visibility == 'followers'"><fa icon="unlock"/></template> + <template v-if="note.visibility == 'specified'"><fa icon="envelope"/></template> + <template v-if="note.visibility == 'private'"><fa icon="lock"/></template> + </span> + <span class="localOnly" v-if="note.localOnly == true"> + <template><fa icon="heart"/></template> + </span> </div> <article> <mk-avatar class="avatar" :user="appearNote.user" v-if="$store.state.device.postStyle != 'smart'"/> @@ -163,9 +172,6 @@ export default Vue.extend({ > span flex-shrink 0 - &:last-of-type - margin-right 8px - .name overflow hidden flex-shrink 1 @@ -179,6 +185,18 @@ export default Vue.extend({ flex-shrink 0 font-size 0.9em + > .visibility + margin-left 8px + + [data-icon] + margin-right 0 + + > .localOnly + margin-left 4px + + [data-icon] + margin-right 0 + & + article padding-top 8px diff --git a/src/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue index df7a5c5a04..f941c59d9f 100644 --- a/src/client/app/mobile/views/components/post-form.vue +++ b/src/client/app/mobile/views/components/post-form.vue @@ -102,6 +102,7 @@ export default Vue.extend({ geo: null, visibility: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility, visibleUsers: [], + localOnly: false, useCw: false, cw: null, recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), @@ -274,7 +275,14 @@ export default Vue.extend({ compact: true }); w.$once('chosen', v => { - this.visibility = v; + const m = v.match(/^local-(.+)/); + if (m) { + this.localOnly = true; + this.visibility = m[1]; + } else { + this.localOnly = false; + this.visibility = v; + } }); }, @@ -320,6 +328,7 @@ export default Vue.extend({ } : null, visibility: this.visibility, visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, + localOnly: this.localOnly, viaMobile: viaMobile }).then(data => { this.$emit('posted'); diff --git a/src/docs/api/entities/note.yaml b/src/docs/api/entities/note.yaml index 6654be2b02..89846a56c7 100644 --- a/src/docs/api/entities/note.yaml +++ b/src/docs/api/entities/note.yaml @@ -26,6 +26,13 @@ props: ja-JP: "モバイル端末から投稿したか否か(自己申告であることに留意)" en-US: "Whether this note sent via a mobile device" + localOnly: + type: "boolean" + optional: true + desc: + ja-JP: "ローカルのみに公開する投稿か否か" + en-US: "Whether this note is no federation" + text: type: "string" optional: true diff --git a/src/models/note.ts b/src/models/note.ts index 516045225c..717960bb23 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -50,6 +50,7 @@ export type INote = { userId: mongo.ObjectID; appId: mongo.ObjectID; viaMobile: boolean; + localOnly: boolean; renoteCount: number; repliesCount: number; reactionCounts: any; diff --git a/src/queue/index.ts b/src/queue/index.ts index 5a48dbe648..8683bcd1df 100644 --- a/src/queue/index.ts +++ b/src/queue/index.ts @@ -6,6 +6,8 @@ export function createHttpJob(data: any) { } export function deliver(user: ILocalUser, content: any, to: any) { + if (content == null) return; + createHttpJob({ type: 'deliver', user, diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts index 7501bf1a89..48a02e79bd 100644 --- a/src/remote/activitypub/models/note.ts +++ b/src/remote/activitypub/models/note.ts @@ -116,6 +116,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false cw: note.summary, text: text, viaMobile: false, + localOnly: false, geo: undefined, visibility, visibleUsers, diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts index 8da933a0f6..888feb08ce 100644 --- a/src/server/activitypub.ts +++ b/src/server/activitypub.ts @@ -66,7 +66,8 @@ router.get('/notes/:note', async (ctx, next) => { const note = await Note.findOne({ _id: new mongo.ObjectID(ctx.params.note), - visibility: { $in: ['public', 'home'] } + visibility: { $in: ['public', 'home'] }, + localOnly: { $ne: true } }); if (note === null) { @@ -83,7 +84,8 @@ router.get('/notes/:note', async (ctx, next) => { router.get('/notes/:note/activity', async ctx => { const note = await Note.findOne({ _id: new mongo.ObjectID(ctx.params.note), - visibility: { $in: ['public', 'home'] } + visibility: { $in: ['public', 'home'] }, + localOnly: { $ne: true } }); if (note === null) { diff --git a/src/server/activitypub/outbox.ts b/src/server/activitypub/outbox.ts index 24d4e3730e..6b917ef843 100644 --- a/src/server/activitypub/outbox.ts +++ b/src/server/activitypub/outbox.ts @@ -55,7 +55,8 @@ export default async (ctx: Router.IRouterContext) => { const query = { userId: user._id, - visibility: { $in: ['public', 'home'] } + visibility: { $in: ['public', 'home'] }, + localOnly: { $ne: true } } as any; if (sinceId) { diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts index a7050e2ec2..4f8d6a4f4f 100644 --- a/src/server/api/endpoints/notes/create.ts +++ b/src/server/api/endpoints/notes/create.ts @@ -74,6 +74,14 @@ export const meta = { } }, + localOnly: { + validator: $.bool.optional, + default: false, + desc: { + 'ja-JP': 'ローカルのみに投稿か否か。' + } + }, + geo: { validator: $.obj({ coordinates: $.arr().length(2) @@ -226,6 +234,7 @@ export default define(meta, (ps, user, app) => new Promise(async (res, rej) => { cw: ps.cw, app, viaMobile: ps.viaMobile, + localOnly: ps.localOnly, visibility: ps.visibility, visibleUsers, geo: ps.geo diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 53d51036b3..0fd983d6c2 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -95,6 +95,7 @@ type Option = { geo?: any; poll?: any; viaMobile?: boolean; + localOnly?: boolean; cw?: string; visibility?: string; visibleUsers?: IUser[]; @@ -109,6 +110,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< if (data.createdAt == null) data.createdAt = new Date(); if (data.visibility == null) data.visibility = 'public'; if (data.viaMobile == null) data.viaMobile = false; + if (data.localOnly == null) data.localOnly = false; if (data.visibleUsers) { data.visibleUsers = erase(null, data.visibleUsers); @@ -139,6 +141,16 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< return rej('Renote target is private of others'); } + // ローカルのみをRenoteしたらローカルのみにする + if (data.renote && data.renote.localOnly) { + data.localOnly = true; + } + + // ローカルのみにリプライしたらローカルのみにする + if (data.reply && data.reply.localOnly) { + data.localOnly = true; + } + if (data.text) { data.text = data.text.trim(); } @@ -308,6 +320,8 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< }); async function renderActivity(data: Option, note: INote) { + if (data.localOnly) return null; + const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length == 0) ? renderAnnounce(data.renote.uri ? data.renote.uri : `${config.url}/notes/${data.renote._id}`, note) : renderCreate(await renderNote(note, false), note); @@ -389,6 +403,7 @@ async function insertNote(user: IUser, data: Option, tags: string[], emojis: str emojis, userId: user._id, viaMobile: data.viaMobile, + localOnly: data.localOnly, geo: data.geo || null, appId: data.app ? data.app._id : null, visibility: data.visibility,