diff options
| author | dakkar <dakkar@thenautilus.net> | 2025-06-04 12:35:20 +0000 |
|---|---|---|
| committer | dakkar <dakkar@thenautilus.net> | 2025-06-04 12:35:20 +0000 |
| commit | e9a038b24464b234bc764d75eadf2eb2bb12c2a8 (patch) | |
| tree | 4c3c3f1c8204a05bff2e545fb21bdd5f30ece839 | |
| parent | merge: Add delay and retry to Page's embedded note loading (!1072) (diff) | |
| parent | remove unused import. (diff) | |
| download | sharkey-e9a038b24464b234bc764d75eadf2eb2bb12c2a8.tar.gz sharkey-e9a038b24464b234bc764d75eadf2eb2bb12c2a8.tar.bz2 sharkey-e9a038b24464b234bc764d75eadf2eb2bb12c2a8.zip | |
merge: Link attributions (!1048)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1048
Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
19 files changed, 252 insertions, 13 deletions
diff --git a/locales/index.d.ts b/locales/index.d.ts index 46bd9d04ed..cee973a0a2 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -13158,6 +13158,18 @@ export interface Locale extends ILocale { */ "translationTimeoutCaption": string; /** + * Attribution Domains + */ + "attributionDomains": string; + /** + * A list of domains whose content can be attributed to you on link previews, separated by new-line. Any subdomain will also be valid. The following needs to be on the webpage: + */ + "attributionDomainsDescription": string; + /** + * Written by {user} + */ + "writtenBy": ParameterizedString<"user">; + /** * Following (Pub) */ "followingPub": string; diff --git a/packages/backend/migration/1748096357260-AddAttributionDomains.js b/packages/backend/migration/1748096357260-AddAttributionDomains.js new file mode 100644 index 0000000000..0a9679bccd --- /dev/null +++ b/packages/backend/migration/1748096357260-AddAttributionDomains.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: piuvas and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddAttributionDomains1748096357260 { + name = 'AddAttributionDomains1748096357260' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "attributionDomains" text array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "attributionDomains"`); + } +} diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index afd011c410..8c1508df24 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -77,6 +77,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser { mandatoryCW: null, rejectQuotes: false, allowUnsignedFetch: 'staff', + attributionDomains: [], ...override, }; } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index f41eeba39f..46a78687f3 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -613,6 +613,7 @@ export class ApRendererService { enableRss: user.enableRss, speakAsCat: user.speakAsCat, attachment: attachment.length ? attachment : undefined, + attributionDomains: user.attributionDomains, }; if (user.movedToUri) { diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index 5c0b8ffcbb..cedd1d8dd5 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -546,6 +546,10 @@ const extension_context_definition = { featured: 'toot:featured', discoverable: 'toot:discoverable', indexable: 'toot:indexable', + attributionDomains: { + '@id': 'toot:attributionDomains', + '@type': '@id', + }, // schema schema: 'http://schema.org#', PropertyValue: 'schema:PropertyValue', diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 2772f47781..dde5762f53 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -445,6 +445,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null, makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null, emojis, + attributionDomains: (Array.isArray(person.attributionDomains) && person.attributionDomains.every(x => typeof x === 'string')) ? person.attributionDomains : [], })) as MiRemoteUser; let _description: string | null = null; @@ -628,6 +629,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { // We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic. hideOnlineStatus: person.hideOnlineStatus !== false, isExplorable: person.discoverable !== false, + attributionDomains: (Array.isArray(person.attributionDomains) && person.attributionDomains.every(x => typeof x === 'string')) ? person.attributionDomains : [], ...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))), } as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>; diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index ae9fe118bc..362c3af1e7 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -265,6 +265,7 @@ export interface IActor extends IObject { enableRss?: boolean; listenbrainz?: string; backgroundUrl?: string; + attributionDomains?: string[]; } export const isCollection = (object: IObject): object is ICollection => diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index feddb8fa94..f6aeb0ef8b 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -603,6 +603,7 @@ export class UserEntityService implements OnModuleInit { enableRss: user.enableRss, mandatoryCW: user.mandatoryCW, rejectQuotes: user.rejectQuotes, + attributionDomains: user.attributionDomains, isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), speakAsCat: user.speakAsCat ?? false, approved: user.approved, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 55b8f4f4f0..3ef5817672 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -389,6 +389,12 @@ export class MiUser { }) public allowUnsignedFetch: UserUnsignedFetchOption; + @Column('varchar', { + name: 'attributionDomains', + length: 128, array: true, default: '{}', + }) + public attributionDomains: string[]; + constructor(data: Partial<MiUser>) { if (data == null) return; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 964a179244..96369c3ad9 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -236,6 +236,14 @@ export const packedUserLiteSchema = { }, }, }, + attributionDomains: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + }, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index f35e395841..dad605f151 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -263,6 +263,9 @@ export const paramDef = { enum: userUnsignedFetchOptions, nullable: false, }, + attributionDomains: { type: 'array', items: { + type: 'string', + } }, }, } as const; @@ -373,6 +376,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- } if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig; + if (ps.attributionDomains !== undefined) updates.attributionDomains = ps.attributionDomains; if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; @@ -663,7 +667,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- // these two methods need to be kept in sync with // `ApRendererService.renderPerson` private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial<MiUser>): boolean { - const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore']; + const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore', 'attributionDomains']; for (const field of basicFields) { if ((field in newUser) && oldUser[field] !== newUser[field]) { return true; diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 203bc908a8..2a300782c6 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -20,6 +20,7 @@ import { RedisKVCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import type { MiAccessToken, NotesRepository } from '@/models/_.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; @@ -30,10 +31,14 @@ import { BucketRateLimit, Keyed, sendRateLimitHeaders } from '@/misc/rate-limit- import type { MiLocalUser } from '@/models/User.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import { isRetryableError } from '@/misc/is-retryable-error.js'; +import * as Acct from '@/misc/acct.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; export type LocalSummalyResult = SummalyResult & { haveNoteLocally?: boolean; + linkAttribution?: { + userId: string, + } }; // Increment this to invalidate cached previews after a major change. @@ -82,6 +87,7 @@ export class UrlPreviewService { private readonly utilityService: UtilityService, private readonly apUtilityService: ApUtilityService, private readonly apDbResolverService: ApDbResolverService, + private readonly remoteUserResolveService: RemoteUserResolveService, private readonly apRequestService: ApRequestService, private readonly systemAccountService: SystemAccountService, private readonly apNoteService: ApNoteService, @@ -206,6 +212,8 @@ export class UrlPreviewService { } } + await this.validateLinkAttribution(summary); + // Await this to avoid hammering redis when a bunch of URLs are fetched at once await this.previewCache.set(cacheKey, summary); @@ -426,6 +434,30 @@ export class UrlPreviewService { } } + private async validateLinkAttribution(summary: LocalSummalyResult) { + if (!summary.fediverseCreator) return; + if (!URL.canParse(summary.url)) return; + + const url = URL.parse(summary.url); + + const acct = Acct.parse(summary.fediverseCreator); + if (acct.host?.toLowerCase() === this.config.host) { + acct.host = null; + } + try { + const user = await this.remoteUserResolveService.resolveUser(acct.username, acct.host); + + const attributionDomains = user.attributionDomains; + if (attributionDomains.some(x => `.${url?.host.toLowerCase()}`.endsWith(`.${x}`))) { + summary.linkAttribution = { + userId: user.id, + }; + } + } catch { + this.logger.debug('User not found: ' + summary.fediverseCreator); + } + } + // Adapted from ApiCallService private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise<boolean> { const [user, app] = auth; diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index a14c2ecef9..69a1540600 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -65,6 +65,17 @@ SPDX-License-Identifier: AGPL-3.0-only </footer> </article> </component> + + <I18n v-if="attributionUser" :src="i18n.ts.writtenBy" :class="$style.linkAttribution" tag="p"> + <template #user> + <MkA v-user-preview="attributionUser.id" :to="userPage(attributionUser)"> + <MkAvatar :class="$style.linkAttributionIcon" :user="attributionUser"/> + <MkUserName :user="attributionUser" style="color: var(--MI_THEME-accent)"/> + </MkA> + </template> + </I18n> + <p v-else-if="linkAttribution" :class="$style.linkAttribution"><MkEllipsis/></p> + <template v-if="showActions"> <div v-if="tweetId" :class="$style.action"> <MkButton :small="true" inline @click="tweetExpanded = true"> @@ -106,6 +117,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { warningExternalWebsite } from '@/utility/warning-external-website.js'; import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue'; import { $i } from '@/i'; +import { userPage } from '@/filters/user.js'; type SummalyResult = Awaited<ReturnType<typeof summaly>>; @@ -146,6 +158,10 @@ const player = ref<SummalyResult['player']>({ height: null, allow: [], }); +const linkAttribution = ref<{ + userId: string, +} | null>(null); +const attributionUser = ref<Misskey.entities.User | null>(null); const playerEnabled = ref(false); const tweetId = ref<string | null>(null); const tweetExpanded = ref(props.detail); @@ -221,7 +237,12 @@ function refresh(withFetch = false) { return res.json(); }) - .then(async (info: SummalyResult & { haveNoteLocally?: boolean } | null) => { + .then(async (info: SummalyResult & { + haveNoteLocally?: boolean, + linkAttribution?: { + userId: string, + } + } | null) => { unknownUrl.value = info == null; title.value = info?.title ?? null; description.value = info?.description ?? null; @@ -236,6 +257,16 @@ function refresh(withFetch = false) { }; sensitive.value = info?.sensitive ?? false; activityPub.value = info?.activityPub ?? null; + linkAttribution.value = info?.linkAttribution ?? null; + if (linkAttribution.value) { + try { + const response = await misskeyApi('users/show', { userId: linkAttribution.value.userId }); + attributionUser.value = response; + } catch { + // makes the loading ellipsis vanish. + linkAttribution.value = null; + } + } theNote.value = null; if (info?.haveNoteLocally) { @@ -395,6 +426,28 @@ refresh(); vertical-align: top; } +.linkAttributionIcon { + display: inline-block; + width: 16px; + height: 16px; + margin-left: 0.25em; + margin-right: 0.25em; + vertical-align: middle; + border-radius: 50%; + * { + border-radius: 4px; + } +} + +.linkAttribution { + width: 100%; + font-size: 0.8em; + display: inline-block; + margin: auto; + padding-top: 0.5em; + text-align: right; +} + .action { display: flex; gap: 6px; diff --git a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue index a0a40e4c72..164179d21c 100644 --- a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue +++ b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue @@ -15,36 +15,50 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, watch } from 'vue'; +import { ref, watch, computed } from 'vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkButton from '@/components/MkButton.vue'; import { ensureSignin } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; const $i = ensureSignin(); const instanceMutes = ref($i.mutedInstances.join('\n')); +const domainArray = computed(() => { + return instanceMutes.value + .trim().split('\n') + .map(el => el.trim().toLowerCase()) + .filter(el => el); +}); const changed = ref(false); async function save() { - let mutes = instanceMutes.value - .trim().split('\n') - .map(el => el.trim()) - .filter(el => el); + // checks for a full line without whitespace. + if (!domainArray.value.every(d => /^\S+$/.test(d))) { + os.alert({ + type: 'error', + title: i18n.ts.invalidValue, + }); + return; + } await misskeyApi('i/update', { - mutedInstances: mutes, + mutedInstances: domainArray.value, }); - changed.value = false; - // Refresh filtered list to signal to the user how they've been saved - instanceMutes.value = mutes.join('\n'); + instanceMutes.value = domainArray.value.join('\n'); + + changed.value = false; } -watch(instanceMutes, () => { - changed.value = true; +watch(domainArray, (newArray, oldArray) => { + // compare arrays + if (newArray.length !== oldArray.length || !newArray.every((a, i) => a === oldArray[i])) { + changed.value = true; + } }); </script> diff --git a/packages/frontend/src/pages/settings/profile.attribution-domains-setting.vue b/packages/frontend/src/pages/settings/profile.attribution-domains-setting.vue new file mode 100644 index 0000000000..c77870f9d3 --- /dev/null +++ b/packages/frontend/src/pages/settings/profile.attribution-domains-setting.vue @@ -0,0 +1,67 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkTextarea v-model="attributionDomains"> + <template #label><SearchLabel>{{ i18n.ts.attributionDomains }}</SearchLabel></template> + <template #caption> + {{ i18n.ts.attributionDomainsDescription }} + <br/> + <Mfm :text="tutorialTag"/> + </template> +</MkTextarea> +<MkButton primary :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> +</template> + +<script lang="ts" setup> +import { ref, watch, computed } from 'vue'; +import { host as hostRaw } from '@@/js/config.js'; +import { toUnicode } from 'punycode.js'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkButton from '@/components/MkButton.vue'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; + +const $i = ensureSignin(); + +const attributionDomains = ref($i.attributionDomains.join('\n')); +const domainArray = computed(() => { + return attributionDomains.value + .trim().split('\n') + .map(el => el.trim().toLowerCase()) + .filter(el => el); +}); +const changed = ref(false); +const tutorialTag = '`<meta name="fediverse:creator" content="' + $i.username + '@' + toUnicode(hostRaw) + '" />`'; + +async function save() { + // checks for a full line without whitespace. + if (!domainArray.value.every(d => /^\S+$/.test(d))) { + os.alert({ + type: 'error', + title: i18n.ts.invalidValue, + }); + return; + } + + await misskeyApi('i/update', { + attributionDomains: domainArray.value, + }); + + // Refresh filtered list to signal to the user how they've been saved + attributionDomains.value = domainArray.value.join('\n'); + + changed.value = false; +} + +watch(domainArray, (newArray, oldArray) => { + // compare arrays + if (newArray.length !== oldArray.length || !newArray.every((a, i) => a === oldArray[i])) { + changed.value = true; + } +}); +</script> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index ee4dd1b65a..21bc74326a 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -163,6 +163,13 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts.flagAsBotDescription }}</template> </MkSwitch> </SearchMarker> + + <SearchMarker + :label="i18n.ts.attributionDomains" + :keywords="['attribution', 'domains', 'preview', 'url']" + > + <AttributionDomainsSettings/> + </SearchMarker> </div> </MkFolder> </SearchMarker> @@ -172,6 +179,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue'; +import AttributionDomainsSettings from './profile.attribution-domains-setting.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index c0c0fd7895..c0694ebb96 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4292,6 +4292,7 @@ export type components = { iconUrl: string | null; displayOrder: number; })[]; + attributionDomains: string[]; }; UserDetailedNotMeOnly: { /** Format: url */ @@ -25185,6 +25186,7 @@ export type operations = { defaultCWPriority?: 'default' | 'parent' | 'defaultParent' | 'parentDefault'; /** @enum {string} */ allowUnsignedFetch?: 'never' | 'always' | 'essential' | 'staff'; + attributionDomains?: string[]; }; }; }; diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index a03092becf..4196e3ea09 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -598,6 +598,10 @@ roleAutomatic: "automatic" translationTimeoutLabel: "Translation timeout" translationTimeoutCaption: "Timeout in milliseconds for translation API requests." +attributionDomains: "Attribution Domains" +attributionDomainsDescription: "A list of domains whose content can be attributed to you on link previews, separated by new-line. Any subdomain will also be valid. The following needs to be on the webpage:" +writtenBy: "Written by {user}" + followingPub: "Following (Pub)" followersSub: "Followers (Sub)" wellKnownResources: "Well-known resources" diff --git a/sharkey-locales/pt-PT.yml b/sharkey-locales/pt-PT.yml index b3f7611ee3..7220cd2b59 100644 --- a/sharkey-locales/pt-PT.yml +++ b/sharkey-locales/pt-PT.yml @@ -7,3 +7,6 @@ openRemoteProfile: "Abrir perfil remoto" allowClickingNotifications: "Permitir clicar em notificações" pinnedOnly: "Fixado" blockingYou: "Bloqueando você" +attributionDomains: "Domínios de Atribuição" +attributionDomainsDescription: "Uma lista de domínios cujo conteúdo pode ser atribuído a você em prévias de link, separadas por linha. Qualquer subdomínio também será válido. O código seguinte precisa estar presente na página:" +writtenBy: "Escrito por {user}" |