feat: add bluesky to accounts, show the same in profile edit and visibility option, in view profile and also in sharing tab

Signed-off-by: yemkareems <yemkareems@gmail.com>
pull/54069/head
yemkareems 3 months ago committed by nextcloud-command
parent 3dac5b33ee
commit aa227f1c55
  1. 18
      apps/federatedfilesharing/src/components/PersonalSettings.vue
  2. 1
      apps/provisioning_api/lib/Controller/AUserDataOCSController.php
  3. 6
      apps/provisioning_api/lib/Controller/UsersController.php
  4. 2
      apps/provisioning_api/lib/ResponseDefinitions.php
  5. 19
      apps/provisioning_api/tests/Controller/UsersControllerTest.php
  6. 7
      apps/settings/lib/Controller/UsersController.php
  7. 1
      apps/settings/lib/Settings/Personal/PersonalInfo.php
  8. 64
      apps/settings/src/components/PersonalInfo/BlueskySection.vue
  9. 5
      apps/settings/src/constants/AccountPropertyConstants.ts
  10. 3
      apps/settings/src/main-personal-info.js
  11. 3
      apps/settings/templates/settings/personal/personal.info.php
  12. 21
      apps/settings/tests/Controller/UsersControllerTest.php
  13. 19
      build/integration/features/provisioning-v1.feature
  14. 1
      core/img/actions/bluesky.svg
  15. 1
      core/img/bluesky.svg
  16. 58
      lib/private/Accounts/AccountManager.php
  17. 65
      lib/private/Profile/Actions/BlueskyAction.php
  18. 2
      lib/private/Profile/ProfileManager.php
  19. 8
      lib/public/Accounts/IAccountManager.php
  20. 1
      lib/public/Profile/IProfileManager.php
  21. 7
      tests/lib/Accounts/AccountManagerTest.php
  22. 1
      tests/lib/Accounts/AccountTest.php

@ -23,25 +23,31 @@
<p class="social-button">
{{ t('federatedfilesharing', 'Share it so your friends can share files with you:') }}<br>
<NcButton @click="goTo(shareFacebookUrl)">
<NcButton :href="shareFacebookUrl">
{{ t('federatedfilesharing', 'Facebook') }}
<template #icon>
<img class="social-button__icon social-button__icon--bright" :src="urlFacebookIcon">
</template>
</NcButton>
<NcButton :aria-label="t('federatedfilesharing', 'X (formerly Twitter)')"
@click="goTo(shareXUrl)">
:href="shareXUrl">
{{ t('federatedfilesharing', 'formerly Twitter') }}
<template #icon>
<img class="social-button__icon" :src="urlXIcon">
</template>
</NcButton>
<NcButton @click="goTo(shareMastodonUrl)">
<NcButton :href="shareMastodonUrl">
{{ t('federatedfilesharing', 'Mastodon') }}
<template #icon>
<img class="social-button__icon" :src="urlMastodonIcon">
</template>
</NcButton>
<NcButton :href="shareBlueSkyUrl">
{{ t('federatedfilesharing', 'Bluesky') }}
<template #icon>
<img class="social-button__icon" :src="urlBlueSkyIcon">
</template>
</NcButton>
<NcButton class="social-button__website-button"
@click="showHtml = !showHtml">
<template #icon>
@ -101,6 +107,7 @@ export default {
reference: loadState<string>('federatedfilesharing', 'reference'),
urlFacebookIcon: imagePath('core', 'facebook'),
urlMastodonIcon: imagePath('core', 'mastodon'),
urlBlueSkyIcon: imagePath('core', 'bluesky'),
urlXIcon: imagePath('core', 'x'),
}
},
@ -130,6 +137,9 @@ export default {
shareFacebookUrl() {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(this.reference)}`
},
shareBlueSkyUrl() {
return `https://bsky.app/intent/compose?text=${encodeURIComponent(this.messageWithURL)}`
},
logoPathAbsolute() {
return window.location.protocol + '//' + window.location.host + this.logoPath
},
@ -176,7 +186,7 @@ export default {
.social-button {
margin-top: 0.5rem;
button {
button, a {
display: inline-flex;
margin-inline-start: 0.5rem;
margin-top: 1rem;

@ -164,6 +164,7 @@ abstract class AUserDataOCSController extends OCSController {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_BLUESKY,
IAccountManager::PROPERTY_FEDIVERSE,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,

@ -788,6 +788,7 @@ class UsersController extends AUserDataOCSController {
$permittedFields[] = IAccountManager::PROPERTY_ADDRESS;
$permittedFields[] = IAccountManager::PROPERTY_WEBSITE;
$permittedFields[] = IAccountManager::PROPERTY_TWITTER;
$permittedFields[] = IAccountManager::PROPERTY_BLUESKY;
$permittedFields[] = IAccountManager::PROPERTY_FEDIVERSE;
$permittedFields[] = IAccountManager::PROPERTY_ORGANISATION;
$permittedFields[] = IAccountManager::PROPERTY_ROLE;
@ -974,6 +975,7 @@ class UsersController extends AUserDataOCSController {
$permittedFields[] = IAccountManager::PROPERTY_ADDRESS;
$permittedFields[] = IAccountManager::PROPERTY_WEBSITE;
$permittedFields[] = IAccountManager::PROPERTY_TWITTER;
$permittedFields[] = IAccountManager::PROPERTY_BLUESKY;
$permittedFields[] = IAccountManager::PROPERTY_FEDIVERSE;
$permittedFields[] = IAccountManager::PROPERTY_ORGANISATION;
$permittedFields[] = IAccountManager::PROPERTY_ROLE;
@ -987,6 +989,7 @@ class UsersController extends AUserDataOCSController {
$permittedFields[] = IAccountManager::PROPERTY_ADDRESS . self::SCOPE_SUFFIX;
$permittedFields[] = IAccountManager::PROPERTY_WEBSITE . self::SCOPE_SUFFIX;
$permittedFields[] = IAccountManager::PROPERTY_TWITTER . self::SCOPE_SUFFIX;
$permittedFields[] = IAccountManager::PROPERTY_BLUESKY . self::SCOPE_SUFFIX;
$permittedFields[] = IAccountManager::PROPERTY_FEDIVERSE . self::SCOPE_SUFFIX;
$permittedFields[] = IAccountManager::PROPERTY_ORGANISATION . self::SCOPE_SUFFIX;
$permittedFields[] = IAccountManager::PROPERTY_ROLE . self::SCOPE_SUFFIX;
@ -1030,6 +1033,7 @@ class UsersController extends AUserDataOCSController {
$permittedFields[] = IAccountManager::PROPERTY_ADDRESS;
$permittedFields[] = IAccountManager::PROPERTY_WEBSITE;
$permittedFields[] = IAccountManager::PROPERTY_TWITTER;
$permittedFields[] = IAccountManager::PROPERTY_BLUESKY;
$permittedFields[] = IAccountManager::PROPERTY_FEDIVERSE;
$permittedFields[] = IAccountManager::PROPERTY_ORGANISATION;
$permittedFields[] = IAccountManager::PROPERTY_ROLE;
@ -1177,6 +1181,7 @@ class UsersController extends AUserDataOCSController {
case IAccountManager::PROPERTY_ADDRESS:
case IAccountManager::PROPERTY_WEBSITE:
case IAccountManager::PROPERTY_TWITTER:
case IAccountManager::PROPERTY_BLUESKY:
case IAccountManager::PROPERTY_FEDIVERSE:
case IAccountManager::PROPERTY_ORGANISATION:
case IAccountManager::PROPERTY_ROLE:
@ -1224,6 +1229,7 @@ class UsersController extends AUserDataOCSController {
case IAccountManager::PROPERTY_ADDRESS . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_WEBSITE . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_TWITTER . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_BLUESKY . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_FEDIVERSE . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_ORGANISATION . self::SCOPE_SUFFIX:
case IAccountManager::PROPERTY_ROLE . self::SCOPE_SUFFIX:

@ -67,6 +67,8 @@ namespace OCA\Provisioning_API;
* subadmin: list<string>,
* twitter: string,
* twitterScope?: Provisioning_APIUserDetailsScope,
* bluesky: string,
* blueskyScope?: Provisioning_APIUserDetailsScope,
* website: string,
* websiteScope?: Provisioning_APIUserDetailsScope,
* }

@ -1143,6 +1143,7 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS => ['value' => 'address'],
IAccountManager::PROPERTY_PHONE => ['value' => 'phone'],
IAccountManager::PROPERTY_TWITTER => ['value' => 'twitter'],
IAccountManager::PROPERTY_BLUESKY => ['value' => 'bluesky'],
IAccountManager::PROPERTY_FEDIVERSE => ['value' => 'fediverse'],
IAccountManager::PROPERTY_WEBSITE => ['value' => 'website'],
IAccountManager::PROPERTY_ORGANISATION => ['value' => 'organisation'],
@ -1219,6 +1220,7 @@ class UsersControllerTest extends TestCase {
'address' => 'address',
'website' => 'website',
'twitter' => 'twitter',
'bluesky' => 'bluesky',
'fediverse' => 'fediverse',
'groups' => ['group0', 'group1', 'group2'],
'language' => 'de',
@ -1332,6 +1334,7 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS => ['value' => 'address'],
IAccountManager::PROPERTY_PHONE => ['value' => 'phone'],
IAccountManager::PROPERTY_TWITTER => ['value' => 'twitter'],
IAccountManager::PROPERTY_BLUESKY => ['value' => 'bluesky'],
IAccountManager::PROPERTY_FEDIVERSE => ['value' => 'fediverse'],
IAccountManager::PROPERTY_WEBSITE => ['value' => 'website'],
IAccountManager::PROPERTY_ORGANISATION => ['value' => 'organisation'],
@ -1364,6 +1367,7 @@ class UsersControllerTest extends TestCase {
'address' => 'address',
'website' => 'website',
'twitter' => 'twitter',
'bluesky' => 'bluesky',
'fediverse' => 'fediverse',
'groups' => [],
'language' => 'da',
@ -1516,6 +1520,7 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS => ['value' => 'address'],
IAccountManager::PROPERTY_PHONE => ['value' => 'phone'],
IAccountManager::PROPERTY_TWITTER => ['value' => 'twitter'],
IAccountManager::PROPERTY_BLUESKY => ['value' => 'bluesky'],
IAccountManager::PROPERTY_FEDIVERSE => ['value' => 'fediverse'],
IAccountManager::PROPERTY_WEBSITE => ['value' => 'website'],
IAccountManager::PROPERTY_ORGANISATION => ['value' => 'organisation'],
@ -1547,6 +1552,7 @@ class UsersControllerTest extends TestCase {
'address' => 'address',
'website' => 'website',
'twitter' => 'twitter',
'bluesky' => 'bluesky',
'fediverse' => 'fediverse',
'groups' => [],
'language' => 'ru',
@ -1894,6 +1900,7 @@ class UsersControllerTest extends TestCase {
public static function selfEditChangePropertyProvider(): array {
return [
[IAccountManager::PROPERTY_TWITTER, '@oldtwitter', '@newtwitter'],
[IAccountManager::PROPERTY_BLUESKY, 'old.bluesky', 'new.bluesky'],
[IAccountManager::PROPERTY_FEDIVERSE, '@oldFediverse@floss.social', '@newFediverse@floss.social'],
[IAccountManager::PROPERTY_PHONE, '1234', '12345'],
[IAccountManager::PROPERTY_ADDRESS, 'Something street 2', 'Another street 3'],
@ -1970,6 +1977,7 @@ class UsersControllerTest extends TestCase {
[IAccountManager::PROPERTY_DISPLAYNAME, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_EMAIL, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_TWITTER, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_BLUESKY, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_FEDIVERSE, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_PHONE, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
[IAccountManager::PROPERTY_ADDRESS, IAccountManager::SCOPE_LOCAL, IAccountManager::SCOPE_FEDERATED],
@ -3856,6 +3864,7 @@ class UsersControllerTest extends TestCase {
'address' => 'address',
'website' => 'website',
'twitter' => 'twitter',
'bluesky' => 'bluesky',
'fediverse' => 'fediverse',
'organisation' => 'organisation',
'role' => 'role',
@ -3877,6 +3886,7 @@ class UsersControllerTest extends TestCase {
'address' => 'address',
'website' => 'website',
'twitter' => 'twitter',
'bluesky' => 'bluesky',
'fediverse' => 'fediverse',
'organisation' => 'organisation',
'role' => 'role',
@ -3944,6 +3954,7 @@ class UsersControllerTest extends TestCase {
'address' => 'address',
'website' => 'website',
'twitter' => 'twitter',
'bluesky' => 'bluesky',
'fediverse' => 'fediverse',
'displayname' => 'Demo User',
'display-name' => 'Demo User',
@ -4286,6 +4297,7 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_BLUESKY,
IAccountManager::PROPERTY_FEDIVERSE,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,
@ -4301,6 +4313,7 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_BLUESKY,
IAccountManager::PROPERTY_FEDIVERSE,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,
@ -4317,6 +4330,7 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_BLUESKY,
IAccountManager::PROPERTY_FEDIVERSE,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,
@ -4331,6 +4345,7 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_BLUESKY,
IAccountManager::PROPERTY_FEDIVERSE,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,
@ -4346,6 +4361,7 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_BLUESKY,
IAccountManager::PROPERTY_FEDIVERSE,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,
@ -4360,6 +4376,7 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_BLUESKY,
IAccountManager::PROPERTY_FEDIVERSE,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,
@ -4375,6 +4392,7 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_BLUESKY,
IAccountManager::PROPERTY_FEDIVERSE,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,
@ -4389,6 +4407,7 @@ class UsersControllerTest extends TestCase {
IAccountManager::PROPERTY_ADDRESS,
IAccountManager::PROPERTY_WEBSITE,
IAccountManager::PROPERTY_TWITTER,
IAccountManager::PROPERTY_BLUESKY,
IAccountManager::PROPERTY_FEDIVERSE,
IAccountManager::PROPERTY_ORGANISATION,
IAccountManager::PROPERTY_ROLE,

@ -319,6 +319,8 @@ class UsersController extends Controller {
* @param string|null $addressScope
* @param string|null $twitter
* @param string|null $twitterScope
* @param string|null $bluesky
* @param string|null $blueskyScope
* @param string|null $fediverse
* @param string|null $fediverseScope
* @param string|null $birthdate
@ -342,6 +344,8 @@ class UsersController extends Controller {
?string $addressScope = null,
?string $twitter = null,
?string $twitterScope = null,
?string $bluesky = null,
?string $blueskyScope = null,
?string $fediverse = null,
?string $fediverseScope = null,
?string $birthdate = null,
@ -386,6 +390,7 @@ class UsersController extends Controller {
IAccountManager::PROPERTY_ADDRESS => ['value' => $address, 'scope' => $addressScope],
IAccountManager::PROPERTY_PHONE => ['value' => $phone, 'scope' => $phoneScope],
IAccountManager::PROPERTY_TWITTER => ['value' => $twitter, 'scope' => $twitterScope],
IAccountManager::PROPERTY_BLUESKY => ['value' => $bluesky, 'scope' => $blueskyScope],
IAccountManager::PROPERTY_FEDIVERSE => ['value' => $fediverse, 'scope' => $fediverseScope],
IAccountManager::PROPERTY_BIRTHDATE => ['value' => $birthdate, 'scope' => $birthdateScope],
IAccountManager::PROPERTY_PRONOUNS => ['value' => $pronouns, 'scope' => $pronounsScope],
@ -428,6 +433,8 @@ class UsersController extends Controller {
'addressScope' => $userAccount->getProperty(IAccountManager::PROPERTY_ADDRESS)->getScope(),
'twitter' => $userAccount->getProperty(IAccountManager::PROPERTY_TWITTER)->getValue(),
'twitterScope' => $userAccount->getProperty(IAccountManager::PROPERTY_TWITTER)->getScope(),
'bluesky' => $userAccount->getProperty(IAccountManager::PROPERTY_BLUESKY)->getValue(),
'blueskyScope' => $userAccount->getProperty(IAccountManager::PROPERTY_BLUESKY)->getScope(),
'fediverse' => $userAccount->getProperty(IAccountManager::PROPERTY_FEDIVERSE)->getValue(),
'fediverseScope' => $userAccount->getProperty(IAccountManager::PROPERTY_FEDIVERSE)->getScope(),
'birthdate' => $userAccount->getProperty(IAccountManager::PROPERTY_BIRTHDATE)->getValue(),

@ -98,6 +98,7 @@ class PersonalInfo implements ISettings {
'location' => $this->getProperty($account, IAccountManager::PROPERTY_ADDRESS),
'website' => $this->getProperty($account, IAccountManager::PROPERTY_WEBSITE),
'twitter' => $this->getProperty($account, IAccountManager::PROPERTY_TWITTER),
'bluesky' => $this->getProperty($account, IAccountManager::PROPERTY_BLUESKY),
'fediverse' => $this->getProperty($account, IAccountManager::PROPERTY_FEDIVERSE),
'languageMap' => $this->getLanguageMap($user),
'localeMap' => $this->getLocaleMap($user),

@ -0,0 +1,64 @@
<!--
- SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<AccountPropertySection v-bind.sync="value"
:readable="readable"
:on-validate="onValidate"
:placeholder="t('settings', 'Bluesky handle')" />
</template>
<script setup lang="ts">
import type { AccountProperties } from '../../constants/AccountPropertyConstants.js'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { ref } from 'vue'
import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.ts'
import AccountPropertySection from './shared/AccountPropertySection.vue'
const { bluesky } = loadState<AccountProperties>('settings', 'personalInfoParameters')
const value = ref({ ...bluesky })
const readable = NAME_READABLE_ENUM[bluesky.name]
/**
* Validate that the text might be a bluesky handle
* @param text The potential bluesky handle
*/
function onValidate(text: string): boolean {
if (text === '') return true;
const lowerText = text.toLowerCase();
if (lowerText === 'bsky.social') {
// Standalone bsky.social is invalid
return false;
}
if (lowerText.endsWith('.bsky.social')) {
// Enforce format: exactly one label + '.bsky.social'
const parts = lowerText.split('.');
// Must be in form: [username, 'bsky', 'social']
if (parts.length !== 3 || parts[1] !== 'bsky' || parts[2] !== 'social') {
return false;
}
const username = parts[0];
const validateRegex = /^[a-z0-9][a-z0-9-]{2,17}$/;
return validateRegex.test(username);
}
// Else, treat as a custom domain
try {
const url = new URL(`https://${text}`);
// Ensure the parsed host matches exactly (case-insensitive already)
return url.host === lowerText;
} catch {
return false;
}
}
</script>

@ -28,6 +28,7 @@ export const ACCOUNT_PROPERTY_ENUM = Object.freeze({
PRONOUNS: 'pronouns',
ROLE: 'role',
TWITTER: 'twitter',
BLUESKY: 'bluesky',
WEBSITE: 'website',
})
@ -48,6 +49,7 @@ export const ACCOUNT_PROPERTY_READABLE_ENUM = Object.freeze({
PRONOUNS: t('settings', 'Pronouns'),
ROLE: t('settings', 'Role'),
TWITTER: t('settings', 'X (formerly Twitter)'),
BLUESKY: t('settings', 'Bluesky'),
WEBSITE: t('settings', 'Website'),
})
@ -64,6 +66,7 @@ export const NAME_READABLE_ENUM = Object.freeze({
[ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED]: ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED,
[ACCOUNT_PROPERTY_ENUM.ROLE]: ACCOUNT_PROPERTY_READABLE_ENUM.ROLE,
[ACCOUNT_PROPERTY_ENUM.TWITTER]: ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER,
[ACCOUNT_PROPERTY_ENUM.BLUESKY]: ACCOUNT_PROPERTY_READABLE_ENUM.BLUESKY,
[ACCOUNT_PROPERTY_ENUM.FEDIVERSE]: ACCOUNT_PROPERTY_READABLE_ENUM.FEDIVERSE,
[ACCOUNT_PROPERTY_ENUM.WEBSITE]: ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE,
[ACCOUNT_PROPERTY_ENUM.BIRTHDATE]: ACCOUNT_PROPERTY_READABLE_ENUM.BIRTHDATE,
@ -89,6 +92,7 @@ export const PROPERTY_READABLE_KEYS_ENUM = Object.freeze({
[ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED]: ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED,
[ACCOUNT_PROPERTY_READABLE_ENUM.ROLE]: ACCOUNT_PROPERTY_ENUM.ROLE,
[ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER]: ACCOUNT_PROPERTY_ENUM.TWITTER,
[ACCOUNT_PROPERTY_READABLE_ENUM.BLUESKY]: ACCOUNT_PROPERTY_ENUM.BLUESKY,
[ACCOUNT_PROPERTY_READABLE_ENUM.FEDIVERSE]: ACCOUNT_PROPERTY_ENUM.FEDIVERSE,
[ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE]: ACCOUNT_PROPERTY_ENUM.WEBSITE,
[ACCOUNT_PROPERTY_READABLE_ENUM.BIRTHDATE]: ACCOUNT_PROPERTY_ENUM.BIRTHDATE,
@ -135,6 +139,7 @@ export const PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM = Object.freeze({
[ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.ROLE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.BLUESKY]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.FEDIVERSE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.BIRTHDATE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],

@ -27,6 +27,7 @@ import ProfileVisibilitySection from './components/PersonalInfo/ProfileVisibilit
import PronounsSection from './components/PersonalInfo/PronounsSection.vue'
import RoleSection from './components/PersonalInfo/RoleSection.vue'
import TwitterSection from './components/PersonalInfo/TwitterSection.vue'
import BlueskySection from './components/PersonalInfo/BlueskySection.vue'
import WebsiteSection from './components/PersonalInfo/WebsiteSection.vue'
__webpack_nonce__ = getCSPNonce()
@ -52,6 +53,7 @@ const LocationView = Vue.extend(LocationSection)
const PhoneView = Vue.extend(PhoneSection)
const PronounsView = Vue.extend(PronounsSection)
const TwitterView = Vue.extend(TwitterSection)
const BlueskyView = Vue.extend(BlueskySection)
const WebsiteView = Vue.extend(WebsiteSection)
new AvatarView().$mount('#vue-avatar-section')
@ -62,6 +64,7 @@ new PhoneView().$mount('#vue-phone-section')
new LocationView().$mount('#vue-location-section')
new WebsiteView().$mount('#vue-website-section')
new TwitterView().$mount('#vue-twitter-section')
new BlueskyView().$mount('#vue-bluesky-section')
new FediverseView().$mount('#vue-fediverse-section')
new LanguageView().$mount('#vue-language-section')
new LocaleView().$mount('#vue-locale-section')

@ -73,6 +73,9 @@ script('settings', [
<div class="personal-settings-setting-box">
<div id="vue-twitter-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-bluesky-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-fediverse-section"></div>
</div>

@ -202,6 +202,11 @@ class UsersControllerTest extends \Test\TestCase {
'Default twitter',
IAccountManager::SCOPE_LOCAL,
),
IAccountManager::PROPERTY_BLUESKY => $this->buildPropertyMock(
IAccountManager::PROPERTY_BLUESKY,
'Default bluesky',
IAccountManager::SCOPE_LOCAL,
),
IAccountManager::PROPERTY_FEDIVERSE => $this->buildPropertyMock(
IAccountManager::PROPERTY_FEDIVERSE,
'Default fediverse',
@ -435,6 +440,8 @@ class UsersControllerTest extends \Test\TestCase {
$addressScope = IAccountManager::SCOPE_PUBLISHED;
$twitter = '@nextclouders';
$twitterScope = IAccountManager::SCOPE_PUBLISHED;
$bluesky = 'nextclouders.net';
$blueskyScope = IAccountManager::SCOPE_PUBLISHED;
$fediverse = '@nextclouders@floss.social';
$fediverseScope = IAccountManager::SCOPE_PUBLISHED;
$birthdate = '2020-01-01';
@ -458,6 +465,8 @@ class UsersControllerTest extends \Test\TestCase {
$expectedProperties[IAccountManager::PROPERTY_ADDRESS]['scope'] = $addressScope;
$expectedProperties[IAccountManager::PROPERTY_TWITTER]['value'] = $twitter;
$expectedProperties[IAccountManager::PROPERTY_TWITTER]['scope'] = $twitterScope;
$expectedProperties[IAccountManager::PROPERTY_BLUESKY]['value'] = $bluesky;
$expectedProperties[IAccountManager::PROPERTY_BLUESKY]['scope'] = $blueskyScope;
$expectedProperties[IAccountManager::PROPERTY_FEDIVERSE]['value'] = $fediverse;
$expectedProperties[IAccountManager::PROPERTY_FEDIVERSE]['scope'] = $fediverseScope;
$expectedProperties[IAccountManager::PROPERTY_BIRTHDATE]['value'] = $birthdate;
@ -486,6 +495,8 @@ class UsersControllerTest extends \Test\TestCase {
$addressScope,
$twitter,
$twitterScope,
$bluesky,
$blueskyScope,
$fediverse,
$fediverseScope,
$birthdate,
@ -524,6 +535,8 @@ class UsersControllerTest extends \Test\TestCase {
$addressScope = ($property === 'addressScope') ? $propertyValue : null;
$twitter = ($property === 'twitter') ? $propertyValue : null;
$twitterScope = ($property === 'twitterScope') ? $propertyValue : null;
$bluesky = ($property === 'bluesky') ? $propertyValue : null;
$blueskyScope = ($property === 'blueskyScope') ? $propertyValue : null;
$fediverse = ($property === 'fediverse') ? $propertyValue : null;
$fediverseScope = ($property === 'fediverseScope') ? $propertyValue : null;
$birthdate = ($property === 'birthdate') ? $propertyValue : null;
@ -562,6 +575,10 @@ class UsersControllerTest extends \Test\TestCase {
case 'twitterScope':
$propertyId = IAccountManager::PROPERTY_TWITTER;
break;
case 'bluesky':
case 'blueskyScope':
$propertyId = IAccountManager::PROPERTY_BLUESKY;
break;
case 'fediverse':
case 'fediverseScope':
$propertyId = IAccountManager::PROPERTY_FEDIVERSE;
@ -604,6 +621,8 @@ class UsersControllerTest extends \Test\TestCase {
$addressScope,
$twitter,
$twitterScope,
$bluesky,
$blueskyScope,
$fediverse,
$fediverseScope,
$birthdate,
@ -628,6 +647,8 @@ class UsersControllerTest extends \Test\TestCase {
['addressScope', IAccountManager::SCOPE_PUBLISHED],
['twitter', '@nextclouders'],
['twitterScope', IAccountManager::SCOPE_PUBLISHED],
['bluesky', 'nextclouders.net'],
['blueskyScope', IAccountManager::SCOPE_PUBLISHED],
['fediverse', '@nextclouders@floss.social'],
['fediverseScope', IAccountManager::SCOPE_PUBLISHED],
['birthdate', '2020-01-01'],

@ -72,7 +72,8 @@ Feature: provisioning
| phone |
| address |
| website |
| twitter |
| twitter |
| bluesky |
| fediverse |
| organisation |
| role |
@ -89,6 +90,7 @@ Feature: provisioning
| address |
| website |
| twitter |
| bluesky |
| fediverse |
| organisation |
| role |
@ -104,6 +106,7 @@ Feature: provisioning
| address |
| website |
| twitter |
| bluesky |
| fediverse |
| organisation |
| role |
@ -158,6 +161,9 @@ Feature: provisioning
And sending "PUT" to "/cloud/users/brand-new-user" with
| key | twitter |
| value | Nextcloud |
And sending "PUT" to "/cloud/users/brand-new-user" with
| key | bluesky |
| value | nextcloud.bsky.social |
And the OCS status code should be "100"
And the HTTP status code should be "200"
Then user "brand-new-user" has
@ -168,7 +174,8 @@ Feature: provisioning
| phone | +4971125242890 |
| address | Foo Bar Town |
| website | https://nextcloud.com |
| twitter | Nextcloud |
| twitter | Nextcloud |
| bluesky | nextcloud.bsky.social |
And sending "PUT" to "/cloud/users/brand-new-user" with
| key | organisation |
| value | Nextcloud GmbH |
@ -212,6 +219,11 @@ Feature: provisioning
| value | v2-local |
Then the OCS status code should be "100"
And the HTTP status code should be "200"
When sending "PUT" to "/cloud/users/brand-new-user" with
| key | blueskyScope |
| value | v2-local |
Then the OCS status code should be "100"
And the HTTP status code should be "200"
When sending "PUT" to "/cloud/users/brand-new-user" with
| key | addressScope |
| value | v2-federated |
@ -247,7 +259,8 @@ Feature: provisioning
Then user "brand-new-user" has
| id | brand-new-user |
| phoneScope | v2-private |
| twitterScope | v2-local |
| twitterScope | v2-local |
| blueskyScope | v2-local |
| addressScope | v2-federated |
| emailScope | v2-published |

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M439.8 358.7C436.5 358.3 433.1 357.9 429.8 357.4C433.2 357.8 436.5 358.3 439.8 358.7zM320 291.1C293.9 240.4 222.9 145.9 156.9 99.3C93.6 54.6 69.5 62.3 53.6 69.5C35.3 77.8 32 105.9 32 122.4C32 138.9 41.1 258 47 277.9C66.5 343.6 136.1 365.8 200.2 358.6C203.5 358.1 206.8 357.7 210.2 357.2C206.9 357.7 203.6 358.2 200.2 358.6C106.3 372.6 22.9 406.8 132.3 528.5C252.6 653.1 297.1 501.8 320 425.1C342.9 501.8 369.2 647.6 505.6 528.5C608 425.1 533.7 372.5 439.8 358.6C436.5 358.2 433.1 357.8 429.8 357.3C433.2 357.7 436.5 358.2 439.8 358.6C503.9 365.7 573.4 343.5 593 277.9C598.9 258 608 139 608 122.4C608 105.8 604.7 77.7 586.4 69.5C570.6 62.4 546.4 54.6 483.2 99.3C417.1 145.9 346.1 240.4 320 291.1z"/></svg>

After

Width:  |  Height:  |  Size: 927 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M439.8 358.7C436.5 358.3 433.1 357.9 429.8 357.4C433.2 357.8 436.5 358.3 439.8 358.7zM320 291.1C293.9 240.4 222.9 145.9 156.9 99.3C93.6 54.6 69.5 62.3 53.6 69.5C35.3 77.8 32 105.9 32 122.4C32 138.9 41.1 258 47 277.9C66.5 343.6 136.1 365.8 200.2 358.6C203.5 358.1 206.8 357.7 210.2 357.2C206.9 357.7 203.6 358.2 200.2 358.6C106.3 372.6 22.9 406.8 132.3 528.5C252.6 653.1 297.1 501.8 320 425.1C342.9 501.8 369.2 647.6 505.6 528.5C608 425.1 533.7 372.5 439.8 358.6C436.5 358.2 433.1 357.8 429.8 357.3C433.2 357.7 436.5 358.2 439.8 358.6C503.9 365.7 573.4 343.5 593 277.9C598.9 258 608 139 608 122.4C608 105.8 604.7 77.7 586.4 69.5C570.6 62.4 546.4 54.6 483.2 99.3C417.1 145.9 346.1 240.4 320 291.1z"/></svg>

After

Width:  |  Height:  |  Size: 927 B

@ -78,6 +78,7 @@ class AccountManager implements IAccountManager {
self::PROPERTY_PRONOUNS => self::SCOPE_FEDERATED,
self::PROPERTY_ROLE => self::SCOPE_LOCAL,
self::PROPERTY_TWITTER => self::SCOPE_LOCAL,
self::PROPERTY_BLUESKY => self::SCOPE_LOCAL,
self::PROPERTY_WEBSITE => self::SCOPE_LOCAL,
];
@ -563,6 +564,13 @@ class AccountManager implements IAccountManager {
'verified' => self::NOT_VERIFIED,
],
[
'name' => self::PROPERTY_BLUESKY,
'value' => '',
'scope' => $scopes[self::PROPERTY_BLUESKY],
'verified' => self::NOT_VERIFIED,
],
[
'name' => self::PROPERTY_FEDIVERSE,
'value' => '',
@ -713,6 +721,47 @@ class AccountManager implements IAccountManager {
}
}
private function validateBlueSkyHandle(string $text): bool {
if ($text === '') {
return true;
}
$lowerText = strtolower($text);
if ($lowerText === 'bsky.social') {
// "bsky.social" itself is not a valid handle
return false;
}
if (str_ends_with($lowerText, '.bsky.social')) {
$parts = explode('.', $lowerText);
// Must be exactly: username.bsky.social → 3 parts
if (count($parts) !== 3 || $parts[1] !== 'bsky' || $parts[2] !== 'social') {
return false;
}
$username = $parts[0];
// Must be 3–18 chars, alphanumeric/hyphen, no start/end hyphen
return preg_match('/^[a-z0-9][a-z0-9-]{2,17}$/', $username) === 1;
}
// Allow custom domains (Bluesky handle via personal domain)
return filter_var($text, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
}
private function sanitizePropertyBluesky(IAccountProperty $property): void {
if ($property->getName() === self::PROPERTY_BLUESKY) {
if (!$this->validateBlueSkyHandle($property->getValue())) {
throw new InvalidArgumentException(self::PROPERTY_BLUESKY);
}
$property->setValue($property->getValue());
}
}
/**
* @throws InvalidArgumentException If the property value is not a valid fediverse handle (username@instance where instance is a valid domain)
*/
@ -804,6 +853,15 @@ class AccountManager implements IAccountManager {
// valid case, nothing to do
}
try {
$property = $account->getProperty(self::PROPERTY_BLUESKY);
if ($property->getValue() !== '') {
$this->sanitizePropertyBluesky($property);
}
} catch (PropertyDoesNotExistException $e) {
// valid case, nothing to do
}
try {
$property = $account->getProperty(self::PROPERTY_FEDIVERSE);
if ($property->getValue() !== '') {

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Profile\Actions;
use OCP\Accounts\IAccountManager;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\L10N\IFactory;
use OCP\Profile\ILinkAction;
class BlueskyAction implements ILinkAction {
private string $value = '';
public function __construct(
private IAccountManager $accountManager,
private IFactory $l10nFactory,
private IURLGenerator $urlGenerator,
) {
}
public function preload(IUser $targetUser): void {
$account = $this->accountManager->getAccount($targetUser);
$this->value = $account->getProperty(IAccountManager::PROPERTY_BLUESKY)->getValue();
}
public function getAppId(): string {
return 'core';
}
public function getId(): string {
return IAccountManager::PROPERTY_BLUESKY;
}
public function getDisplayId(): string {
return $this->l10nFactory->get('lib')->t('Bluesky');
}
public function getTitle(): string {
$displayUsername = $this->value;
return $this->l10nFactory->get('lib')->t('View %s on Bluesky', [$displayUsername]);
}
public function getPriority(): int {
return 60;
}
public function getIcon(): string {
return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/bluesky.svg'));
}
public function getTarget(): ?string {
if (empty($this->value)) {
return null;
}
$username = $this->value;
return 'https://bsky.app/profile/' . $username;
}
}

@ -14,6 +14,7 @@ use OC\Core\Db\ProfileConfig;
use OC\Core\Db\ProfileConfigMapper;
use OC\Core\ResponseDefinitions;
use OC\KnownUser\KnownUserService;
use OC\Profile\Actions\BlueskyAction;
use OC\Profile\Actions\EmailAction;
use OC\Profile\Actions\FediverseAction;
use OC\Profile\Actions\PhoneAction;
@ -56,6 +57,7 @@ class ProfileManager implements IProfileManager {
PhoneAction::class,
WebsiteAction::class,
TwitterAction::class,
BlueskyAction::class,
FediverseAction::class,
];

@ -96,10 +96,15 @@ interface IAccountManager {
public const PROPERTY_ADDRESS = 'address';
/**
* @since 15.0.0
* @deprecated 32.0.0
*/
public const PROPERTY_TWITTER = 'twitter';
/**
* @since 32.0.0
*/
public const PROPERTY_BLUESKY = 'bluesky';
/**
* @since 26.0.0
*/
@ -160,6 +165,7 @@ interface IAccountManager {
self::PROPERTY_PRONOUNS,
self::PROPERTY_ROLE,
self::PROPERTY_TWITTER,
self::PROPERTY_BLUESKY,
self::PROPERTY_WEBSITE,
];

@ -55,6 +55,7 @@ interface IProfileManager {
IAccountManager::PROPERTY_EMAIL => self::VISIBILITY_SHOW_USERS_ONLY,
IAccountManager::PROPERTY_PHONE => self::VISIBILITY_SHOW_USERS_ONLY,
IAccountManager::PROPERTY_TWITTER => self::VISIBILITY_SHOW,
IAccountManager::PROPERTY_BLUESKY => self::VISIBILITY_SHOW,
IAccountManager::PROPERTY_WEBSITE => self::VISIBILITY_SHOW,
IAccountManager::PROPERTY_PRONOUNS => self::VISIBILITY_SHOW,
];

@ -574,6 +574,13 @@ class AccountManagerTest extends TestCase {
'verified' => IAccountManager::NOT_VERIFIED,
],
[
'name' => IAccountManager::PROPERTY_BLUESKY,
'value' => '',
'scope' => IAccountManager::SCOPE_LOCAL,
'verified' => IAccountManager::NOT_VERIFIED,
],
[
'name' => IAccountManager::PROPERTY_FEDIVERSE,
'value' => '',

@ -64,6 +64,7 @@ class AccountTest extends TestCase {
IAccountManager::PROPERTY_AVATAR => new AccountProperty(IAccountManager::PROPERTY_AVATAR, '', IAccountManager::SCOPE_PUBLISHED, IAccountManager::NOT_VERIFIED, ''),
IAccountManager::PROPERTY_PHONE => new AccountProperty(IAccountManager::PROPERTY_PHONE, '+358407991028', IAccountManager::SCOPE_LOCAL, IAccountManager::NOT_VERIFIED, ''),
IAccountManager::PROPERTY_TWITTER => new AccountProperty(IAccountManager::PROPERTY_TWITTER, 'therealsteve', IAccountManager::SCOPE_PRIVATE, IAccountManager::NOT_VERIFIED, ''),
IAccountManager::PROPERTY_BLUESKY => new AccountProperty(IAccountManager::PROPERTY_BLUESKY, 'therealsteve.bsky.social', IAccountManager::SCOPE_PRIVATE, IAccountManager::NOT_VERIFIED, ''),
IAccountManager::PROPERTY_ORGANISATION => new AccountProperty(IAccountManager::PROPERTY_ORGANISATION, 'Steve Incorporated', IAccountManager::SCOPE_FEDERATED, IAccountManager::NOT_VERIFIED, ''),
IAccountManager::PROPERTY_ROLE => new AccountProperty(IAccountManager::PROPERTY_ROLE, 'Founder', IAccountManager::SCOPE_FEDERATED, IAccountManager::NOT_VERIFIED, ''),
IAccountManager::PROPERTY_HEADLINE => new AccountProperty(IAccountManager::PROPERTY_HEADLINE, 'I am Steve', IAccountManager::SCOPE_PUBLISHED, IAccountManager::NOT_VERIFIED, ''),

Loading…
Cancel
Save