Merge pull request #16313 from nextcloud/feat/noid/split-chat-backend

feat(splitview): add option to toggle between chat views
This commit is contained in:
Dorra 2025-11-17 17:17:43 +01:00 committed by GitHub
commit a3d644d89e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 195 additions and 14 deletions

View file

@ -201,3 +201,4 @@
## 23 ## 23
* `pinned-messages` - Whether messages can be pinned * `pinned-messages` - Whether messages can be pinned
* `federated-shared-items` - Whether shared items endpoints can be called in a federated conversation * `federated-shared-items` - Whether shared items endpoints can be called in a federated conversation
* `config => chat => style` (local) - User selected chat style (split or unified for now)

View file

@ -176,6 +176,7 @@ class Capabilities implements IPublicCapability {
'has-translation-task-providers', 'has-translation-task-providers',
'typing-privacy', 'typing-privacy',
'summary-threshold', 'summary-threshold',
'style',
], ],
'conversations' => [ 'conversations' => [
'can-create', 'can-create',
@ -263,6 +264,7 @@ class Capabilities implements IPublicCapability {
'has-translation-task-providers' => false, 'has-translation-task-providers' => false,
'typing-privacy' => Participant::PRIVACY_PUBLIC, 'typing-privacy' => Participant::PRIVACY_PUBLIC,
'summary-threshold' => max(1, $this->appConfig->getAppValueInt('summary_threshold', 100)), 'summary-threshold' => max(1, $this->appConfig->getAppValueInt('summary_threshold', 100)),
'style' => $this->talkConfig->getChatStyle($user?->getUID()),
], ],
'conversations' => [ 'conversations' => [
'can-create' => $user instanceof IUser && !$this->talkConfig->isNotAllowedToCreateConversations($user), 'can-create' => $user instanceof IUser && !$this->talkConfig->isNotAllowedToCreateConversations($user),

View file

@ -757,6 +757,28 @@ class Config {
return UserPreference::CONVERSATION_LIST_STYLE_TWO_LINES; return UserPreference::CONVERSATION_LIST_STYLE_TWO_LINES;
} }
/**
* User setting for chat style
*
* @param ?string $userId
* @return UserPreference::CHAT_STYLE_*
*/
public function getChatStyle(?string $userId): string {
if ($userId !== null) {
$userSetting = $this->config->getUserValue(
$userId,
'spreed',
UserPreference::CHAT_STYLE,
UserPreference::CHAT_STYLE_SPLIT
);
if (in_array($userSetting, [UserPreference::CHAT_STYLE_SPLIT, UserPreference::CHAT_STYLE_UNIFIED], true)) {
return $userSetting;
}
}
return UserPreference::CHAT_STYLE_SPLIT;
}
/** /**
* User setting falling back to admin defined app config * User setting falling back to admin defined app config
*/ */

View file

@ -31,6 +31,7 @@ class ConfigLexicon implements ILexicon {
public function getUserConfigs(): array { public function getUserConfigs(): array {
return [ return [
new Entry(UserPreference::PLAY_SOUNDS, ValueType::BOOL, true), new Entry(UserPreference::PLAY_SOUNDS, ValueType::BOOL, true),
new Entry(UserPreference::CHAT_STYLE, ValueType::STRING, UserPreference::CHAT_STYLE_SPLIT),
]; ];
} }
} }

View file

@ -524,6 +524,7 @@ namespace OCA\Talk;
* has-translation-task-providers: bool, * has-translation-task-providers: bool,
* typing-privacy: int, * typing-privacy: int,
* summary-threshold: positive-int, * summary-threshold: positive-int,
* style: 'split'|'unified',
* }, * },
* conversations: array{ * conversations: array{
* can-create: bool, * can-create: bool,

View file

@ -81,6 +81,9 @@ class BeforePreferenceSetEventListener implements IEventListener {
if ($key === UserPreference::CONVERSATIONS_LIST_STYLE) { if ($key === UserPreference::CONVERSATIONS_LIST_STYLE) {
return $value === UserPreference::CONVERSATION_LIST_STYLE_TWO_LINES || $value === UserPreference::CONVERSATION_LIST_STYLE_COMPACT; return $value === UserPreference::CONVERSATION_LIST_STYLE_TWO_LINES || $value === UserPreference::CONVERSATION_LIST_STYLE_COMPACT;
} }
if ($key === UserPreference::CHAT_STYLE) {
return $value === UserPreference::CHAT_STYLE_SPLIT || $value === UserPreference::CHAT_STYLE_UNIFIED;
}
return false; return false;
} }

View file

@ -13,6 +13,7 @@ class UserPreference {
public const BLUR_VIRTUAL_BACKGROUND = 'blur_virtual_background'; public const BLUR_VIRTUAL_BACKGROUND = 'blur_virtual_background';
public const CALLS_START_WITHOUT_MEDIA = 'calls_start_without_media'; public const CALLS_START_WITHOUT_MEDIA = 'calls_start_without_media';
public const CONVERSATIONS_LIST_STYLE = 'conversations_list_style'; public const CONVERSATIONS_LIST_STYLE = 'conversations_list_style';
public const CHAT_STYLE = 'chat_style';
public const PLAY_SOUNDS = 'play_sounds'; public const PLAY_SOUNDS = 'play_sounds';
public const TYPING_PRIVACY = 'typing_privacy'; public const TYPING_PRIVACY = 'typing_privacy';
public const READ_STATUS_PRIVACY = 'read_status_privacy'; public const READ_STATUS_PRIVACY = 'read_status_privacy';
@ -20,4 +21,7 @@ class UserPreference {
public const CONVERSATION_LIST_STYLE_TWO_LINES = 'two-lines'; public const CONVERSATION_LIST_STYLE_TWO_LINES = 'two-lines';
public const CONVERSATION_LIST_STYLE_COMPACT = 'compact'; public const CONVERSATION_LIST_STYLE_COMPACT = 'compact';
public const CHAT_STYLE_SPLIT = 'split';
public const CHAT_STYLE_UNIFIED = 'unified';
} }

View file

@ -230,7 +230,8 @@
"has-translation-providers", "has-translation-providers",
"has-translation-task-providers", "has-translation-task-providers",
"typing-privacy", "typing-privacy",
"summary-threshold" "summary-threshold",
"style"
], ],
"properties": { "properties": {
"max-length": { "max-length": {
@ -255,6 +256,13 @@
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
"minimum": 1 "minimum": 1
},
"style": {
"type": "string",
"enum": [
"split",
"unified"
]
} }
} }
}, },

View file

@ -163,7 +163,8 @@
"has-translation-providers", "has-translation-providers",
"has-translation-task-providers", "has-translation-task-providers",
"typing-privacy", "typing-privacy",
"summary-threshold" "summary-threshold",
"style"
], ],
"properties": { "properties": {
"max-length": { "max-length": {
@ -188,6 +189,13 @@
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
"minimum": 1 "minimum": 1
},
"style": {
"type": "string",
"enum": [
"split",
"unified"
]
} }
} }
}, },

View file

@ -163,7 +163,8 @@
"has-translation-providers", "has-translation-providers",
"has-translation-task-providers", "has-translation-task-providers",
"typing-privacy", "typing-privacy",
"summary-threshold" "summary-threshold",
"style"
], ],
"properties": { "properties": {
"max-length": { "max-length": {
@ -188,6 +189,13 @@
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
"minimum": 1 "minimum": 1
},
"style": {
"type": "string",
"enum": [
"split",
"unified"
]
} }
} }
}, },

View file

@ -206,7 +206,8 @@
"has-translation-providers", "has-translation-providers",
"has-translation-task-providers", "has-translation-task-providers",
"typing-privacy", "typing-privacy",
"summary-threshold" "summary-threshold",
"style"
], ],
"properties": { "properties": {
"max-length": { "max-length": {
@ -231,6 +232,13 @@
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
"minimum": 1 "minimum": 1
},
"style": {
"type": "string",
"enum": [
"split",
"unified"
]
} }
} }
}, },

View file

@ -163,7 +163,8 @@
"has-translation-providers", "has-translation-providers",
"has-translation-task-providers", "has-translation-task-providers",
"typing-privacy", "typing-privacy",
"summary-threshold" "summary-threshold",
"style"
], ],
"properties": { "properties": {
"max-length": { "max-length": {
@ -188,6 +189,13 @@
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
"minimum": 1 "minimum": 1
},
"style": {
"type": "string",
"enum": [
"split",
"unified"
]
} }
} }
}, },

View file

@ -206,7 +206,8 @@
"has-translation-providers", "has-translation-providers",
"has-translation-task-providers", "has-translation-task-providers",
"typing-privacy", "typing-privacy",
"summary-threshold" "summary-threshold",
"style"
], ],
"properties": { "properties": {
"max-length": { "max-length": {
@ -231,6 +232,13 @@
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
"minimum": 1 "minimum": 1
},
"style": {
"type": "string",
"enum": [
"split",
"unified"
]
} }
} }
}, },

View file

@ -364,7 +364,8 @@
"has-translation-providers", "has-translation-providers",
"has-translation-task-providers", "has-translation-task-providers",
"typing-privacy", "typing-privacy",
"summary-threshold" "summary-threshold",
"style"
], ],
"properties": { "properties": {
"max-length": { "max-length": {
@ -389,6 +390,13 @@
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
"minimum": 1 "minimum": 1
},
"style": {
"type": "string",
"enum": [
"split",
"unified"
]
} }
} }
}, },

View file

@ -323,7 +323,8 @@
"has-translation-providers", "has-translation-providers",
"has-translation-task-providers", "has-translation-task-providers",
"typing-privacy", "typing-privacy",
"summary-threshold" "summary-threshold",
"style"
], ],
"properties": { "properties": {
"max-length": { "max-length": {
@ -348,6 +349,13 @@
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
"minimum": 1 "minimum": 1
},
"style": {
"type": "string",
"enum": [
"split",
"unified"
]
} }
} }
}, },

View file

@ -159,6 +159,7 @@ export const mockedCapabilities: Capabilities = {
'has-translation-task-providers': true, 'has-translation-task-providers': true,
'typing-privacy': 0, 'typing-privacy': 0,
'summary-threshold': 100, 'summary-threshold': 100,
style: 'split',
}, },
conversations: { conversations: {
'can-create': true, 'can-create': true,

View file

@ -55,11 +55,20 @@
v-if="!isGuest && supportConversationsListStyle" v-if="!isGuest && supportConversationsListStyle"
id="talk_appearance" id="talk_appearance"
:name="t('spreed', 'Appearance & Sounds')"> :name="t('spreed', 'Appearance & Sounds')">
<NcFormBoxSwitch <NcFormBox>
:model-value="conversationsListStyle" <NcFormBoxSwitch
:label="t('spreed', 'Compact conversations list')" :model-value="conversationsListStyle"
:disabled="appearanceLoading" :label="t('spreed', 'Compact conversations list')"
@update:model-value="toggleConversationsListStyle" /> :disabled="appearanceLoading"
@update:model-value="toggleConversationsListStyle" />
<!-- FIXME: remove v-if after implementing split view -->
<NcFormBoxSwitch
v-if="false"
:model-value="chatSplitViewEnabled"
:label="t('spreed', 'Show your chat in split view')"
:disabled="chatAppearanceLoading"
@update:model-value="toggleChatStyle" />
</NcFormBox>
<NcFormBox> <NcFormBox>
<NcFormBoxSwitch <NcFormBoxSwitch
@ -167,7 +176,7 @@ import NcHotkeyList from '@nextcloud/vue/components/NcHotkeyList'
import NcKbd from '@nextcloud/vue/components/NcKbd' import NcKbd from '@nextcloud/vue/components/NcKbd'
import IconFolderOpenOutline from 'vue-material-design-icons/FolderOpenOutline.vue' import IconFolderOpenOutline from 'vue-material-design-icons/FolderOpenOutline.vue'
import IconMicrophoneOutline from 'vue-material-design-icons/MicrophoneOutline.vue' import IconMicrophoneOutline from 'vue-material-design-icons/MicrophoneOutline.vue'
import { CONVERSATION, PRIVACY } from '../../constants.ts' import { CHAT_STYLE, CONVERSATION, PRIVACY } from '../../constants.ts'
import { getTalkConfig, getTalkVersion } from '../../services/CapabilitiesManager.ts' import { getTalkConfig, getTalkVersion } from '../../services/CapabilitiesManager.ts'
import { useCustomSettings } from '../../services/SettingsAPI.ts' import { useCustomSettings } from '../../services/SettingsAPI.ts'
import { useActorStore } from '../../stores/actor.ts' import { useActorStore } from '../../stores/actor.ts'
@ -228,6 +237,7 @@ export default {
return { return {
showSettings: false, showSettings: false,
attachmentFolderLoading: true, attachmentFolderLoading: true,
chatAppearanceLoading: false,
appearanceLoading: false, appearanceLoading: false,
privacyLoading: false, privacyLoading: false,
playSoundsLoading: false, playSoundsLoading: false,
@ -267,6 +277,10 @@ export default {
hideMediaSettings() { hideMediaSettings() {
return !this.settingsStore.showMediaSettings return !this.settingsStore.showMediaSettings
}, },
chatSplitViewEnabled() {
return this.settingsStore.chatStyle === CHAT_STYLE.SPLIT
},
}, },
mounted() { mounted() {
@ -350,6 +364,17 @@ export default {
this.appearanceLoading = false this.appearanceLoading = false
}, },
async toggleChatStyle(value) {
this.chatAppearanceLoading = true
try {
await this.settingsStore.updateChatStyle(value ? CHAT_STYLE.SPLIT : CHAT_STYLE.UNIFIED)
showSuccess(t('spreed', 'Your personal setting has been saved'))
} catch (exception) {
showError(t('spreed', 'Error while setting personal setting'))
}
this.chatAppearanceLoading = false
},
async togglePlaySounds() { async togglePlaySounds() {
this.playSoundsLoading = true this.playSoundsLoading = true
try { try {

View file

@ -47,6 +47,11 @@ export const CHAT = {
FETCH_NEW: 1, FETCH_NEW: 1,
} as const } as const
export const CHAT_STYLE = {
SPLIT: 'split',
UNIFIED: 'unified',
} as const
export const CALL = { export const CALL = {
RECORDING: { RECORDING: {
OFF: 0, OFF: 0,

View file

@ -108,6 +108,14 @@ async function setConversationsListStyle(value: string) {
return setUserConfig('spreed', 'conversations_list_style', value) return setUserConfig('spreed', 'conversations_list_style', value)
} }
/**
*
* @param value
*/
async function setChatStyle(value: string) {
return setUserConfig('spreed', 'chat_style', value)
}
/** /**
* Set user config using provisioning API * Set user config using provisioning API
* *
@ -124,6 +132,7 @@ async function setUserConfig(appId: string, configKey: string, configValue: stri
export { export {
setAttachmentFolder, setAttachmentFolder,
setBlurVirtualBackground, setBlurVirtualBackground,
setChatStyle,
setConversationsListStyle, setConversationsListStyle,
setPlaySounds, setPlaySounds,
setReadStatusPrivacy, setReadStatusPrivacy,

View file

@ -12,6 +12,7 @@ import { getTalkConfig } from '../services/CapabilitiesManager.ts'
import { import {
setAttachmentFolder, setAttachmentFolder,
setBlurVirtualBackground, setBlurVirtualBackground,
setChatStyle,
setConversationsListStyle, setConversationsListStyle,
setReadStatusPrivacy, setReadStatusPrivacy,
setStartWithoutMedia, setStartWithoutMedia,
@ -20,6 +21,7 @@ import {
type PRIVACY_KEYS = typeof PRIVACY[keyof typeof PRIVACY] type PRIVACY_KEYS = typeof PRIVACY[keyof typeof PRIVACY]
type LIST_STYLE_OPTIONS = 'two-lines' | 'compact' type LIST_STYLE_OPTIONS = 'two-lines' | 'compact'
type CHAT_STYLE_OPTIONS = 'split' | 'unified'
/** /**
* Store for shared items shown in RightSidebar * Store for shared items shown in RightSidebar
@ -31,6 +33,7 @@ export const useSettingsStore = defineStore('settings', () => {
const startWithoutMedia = ref<boolean | undefined>(getTalkConfig('local', 'call', 'start-without-media')) const startWithoutMedia = ref<boolean | undefined>(getTalkConfig('local', 'call', 'start-without-media'))
const blurVirtualBackgroundEnabled = ref<boolean | undefined>(getTalkConfig('local', 'call', 'blur-virtual-background')) const blurVirtualBackgroundEnabled = ref<boolean | undefined>(getTalkConfig('local', 'call', 'blur-virtual-background'))
const conversationsListStyle = ref<LIST_STYLE_OPTIONS | undefined>(getTalkConfig('local', 'conversations', 'list-style')) const conversationsListStyle = ref<LIST_STYLE_OPTIONS | undefined>(getTalkConfig('local', 'conversations', 'list-style'))
const chatStyle = ref<CHAT_STYLE_OPTIONS | undefined>(getTalkConfig('local', 'chat', 'style') ?? 'split')
const attachmentFolder = ref<string>(loadState('spreed', 'attachment_folder', '')) const attachmentFolder = ref<string>(loadState('spreed', 'attachment_folder', ''))
const attachmentFolderFreeSpace = ref<number>(loadState('spreed', 'attachment_folder_free_space', 0)) const attachmentFolderFreeSpace = ref<number>(loadState('spreed', 'attachment_folder_free_space', 0))
@ -105,6 +108,16 @@ export const useSettingsStore = defineStore('settings', () => {
attachmentFolder.value = value attachmentFolder.value = value
} }
/**
* Update the conversations list style setting for the user
*
* @param value - new selected state
*/
async function updateChatStyle(value: CHAT_STYLE_OPTIONS) {
await setChatStyle(value)
chatStyle.value = value
}
return { return {
readStatusPrivacy, readStatusPrivacy,
typingStatusPrivacy, typingStatusPrivacy,
@ -114,6 +127,7 @@ export const useSettingsStore = defineStore('settings', () => {
conversationsListStyle, conversationsListStyle,
attachmentFolder, attachmentFolder,
attachmentFolderFreeSpace, attachmentFolderFreeSpace,
chatStyle,
updateReadStatusPrivacy, updateReadStatusPrivacy,
updateTypingStatusPrivacy, updateTypingStatusPrivacy,
@ -122,5 +136,6 @@ export const useSettingsStore = defineStore('settings', () => {
updateStartWithoutMedia, updateStartWithoutMedia,
updateConversationsListStyle, updateConversationsListStyle,
updateAttachmentFolder, updateAttachmentFolder,
updateChatStyle,
} }
}) })

View file

@ -249,6 +249,8 @@ export type components = {
"typing-privacy": number; "typing-privacy": number;
/** Format: int64 */ /** Format: int64 */
"summary-threshold": number; "summary-threshold": number;
/** @enum {string} */
style: "split" | "unified";
}; };
conversations: { conversations: {
"can-create": boolean; "can-create": boolean;

View file

@ -83,6 +83,8 @@ export type components = {
"typing-privacy": number; "typing-privacy": number;
/** Format: int64 */ /** Format: int64 */
"summary-threshold": number; "summary-threshold": number;
/** @enum {string} */
style: "split" | "unified";
}; };
conversations: { conversations: {
"can-create": boolean; "can-create": boolean;

View file

@ -69,6 +69,8 @@ export type components = {
"typing-privacy": number; "typing-privacy": number;
/** Format: int64 */ /** Format: int64 */
"summary-threshold": number; "summary-threshold": number;
/** @enum {string} */
style: "split" | "unified";
}; };
conversations: { conversations: {
"can-create": boolean; "can-create": boolean;

View file

@ -184,6 +184,8 @@ export type components = {
"typing-privacy": number; "typing-privacy": number;
/** Format: int64 */ /** Format: int64 */
"summary-threshold": number; "summary-threshold": number;
/** @enum {string} */
style: "split" | "unified";
}; };
conversations: { conversations: {
"can-create": boolean; "can-create": boolean;

View file

@ -87,6 +87,8 @@ export type components = {
"typing-privacy": number; "typing-privacy": number;
/** Format: int64 */ /** Format: int64 */
"summary-threshold": number; "summary-threshold": number;
/** @enum {string} */
style: "split" | "unified";
}; };
conversations: { conversations: {
"can-create": boolean; "can-create": boolean;

View file

@ -195,6 +195,8 @@ export type components = {
"typing-privacy": number; "typing-privacy": number;
/** Format: int64 */ /** Format: int64 */
"summary-threshold": number; "summary-threshold": number;
/** @enum {string} */
style: "split" | "unified";
}; };
conversations: { conversations: {
"can-create": boolean; "can-create": boolean;

View file

@ -2391,6 +2391,8 @@ export type components = {
"typing-privacy": number; "typing-privacy": number;
/** Format: int64 */ /** Format: int64 */
"summary-threshold": number; "summary-threshold": number;
/** @enum {string} */
style: "split" | "unified";
}; };
conversations: { conversations: {
"can-create": boolean; "can-create": boolean;

View file

@ -1869,6 +1869,8 @@ export type components = {
"typing-privacy": number; "typing-privacy": number;
/** Format: int64 */ /** Format: int64 */
"summary-threshold": number; "summary-threshold": number;
/** @enum {string} */
style: "split" | "unified";
}; };
conversations: { conversations: {
"can-create": boolean; "can-create": boolean;

View file

@ -104,6 +104,11 @@ class CapabilitiesTest extends TestCase {
->method('isBreakoutRoomsEnabled') ->method('isBreakoutRoomsEnabled')
->willReturn(false); ->willReturn(false);
$this->talkConfig->expects($this->once())
->method('getChatStyle')
->with(null)
->willReturn('split');
$this->serverConfig->expects($this->any()) $this->serverConfig->expects($this->any())
->method('getAppValue') ->method('getAppValue')
->willReturnMap([ ->willReturnMap([
@ -181,6 +186,7 @@ class CapabilitiesTest extends TestCase {
'has-translation-task-providers' => false, 'has-translation-task-providers' => false,
'typing-privacy' => 0, 'typing-privacy' => 0,
'summary-threshold' => 100, 'summary-threshold' => 100,
'style' => 'split',
], ],
'conversations' => [ 'conversations' => [
'can-create' => false, 'can-create' => false,
@ -247,6 +253,11 @@ class CapabilitiesTest extends TestCase {
->with('uid') ->with('uid')
->willReturn('/Talk'); ->willReturn('/Talk');
$this->talkConfig->expects($this->once())
->method('getChatStyle')
->with('uid')
->willReturn('split');
$this->talkConfig->expects($this->once()) $this->talkConfig->expects($this->once())
->method('isNotAllowedToCreateConversations') ->method('isNotAllowedToCreateConversations')
->with($user) ->with($user)
@ -353,6 +364,7 @@ class CapabilitiesTest extends TestCase {
'has-translation-task-providers' => false, 'has-translation-task-providers' => false,
'typing-privacy' => 0, 'typing-privacy' => 0,
'summary-threshold' => 100, 'summary-threshold' => 100,
'style' => 'split',
], ],
'conversations' => [ 'conversations' => [
'can-create' => $canCreate, 'can-create' => $canCreate,