PersonalSettingAvatar.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { storeToRefs } from 'pinia'
  4. import { computed, useTemplateRef } from 'vue'
  5. import CommonAvatar from '#shared/components/CommonAvatar/CommonAvatar.vue'
  6. import { NotificationTypes } from '#shared/components/CommonNotifications/types.ts'
  7. import { useNotifications } from '#shared/components/CommonNotifications/useNotifications.ts'
  8. import CommonUserAvatar from '#shared/components/CommonUserAvatar/CommonUserAvatar.vue'
  9. import { useConfirmation } from '#shared/composables/useConfirmation.ts'
  10. import { useTouchDevice } from '#shared/composables/useTouchDevice.ts'
  11. import { useUserCurrentAvatarAddMutation } from '#shared/entities/user/current/graphql/mutations/userCurrentAvatarAdd.api.ts'
  12. import { useUserCurrentAvatarDeleteMutation } from '#shared/entities/user/current/graphql/mutations/userCurrentAvatarDelete.api.ts'
  13. import type {
  14. UserCurrentAvatarUpdatesSubscriptionVariables,
  15. UserCurrentAvatarUpdatesSubscription,
  16. Avatar,
  17. UserCurrentAvatarListQuery,
  18. } from '#shared/graphql/types.ts'
  19. import { getApolloClient } from '#shared/server/apollo/client.ts'
  20. import MutationHandler from '#shared/server/apollo/handler/MutationHandler.ts'
  21. import QueryHandler from '#shared/server/apollo/handler/QueryHandler.ts'
  22. import { useApplicationStore } from '#shared/stores/application.ts'
  23. import { useSessionStore } from '#shared/stores/session.ts'
  24. import type { ImageFileData } from '#shared/utils/files.ts'
  25. import {
  26. convertFileList,
  27. allowedImageTypesString,
  28. } from '#shared/utils/files.ts'
  29. import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
  30. import CommonDivider from '#desktop/components/CommonDivider/CommonDivider.vue'
  31. import { useFlyout } from '#desktop/components/CommonFlyout/useFlyout.ts'
  32. import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
  33. import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
  34. import { useBreadcrumb } from '../composables/useBreadcrumb.ts'
  35. import { useUserCurrentAvatarSelectMutation } from '../graphql/mutations/userCurrentAvatarSelect.api.ts'
  36. import {
  37. useUserCurrentAvatarListQuery,
  38. UserCurrentAvatarListDocument,
  39. } from '../graphql/queries/userCurrentAvatarList.api.ts'
  40. import { UserCurrentAvatarUpdatesDocument } from '../graphql/subscriptions/userCurrentAvatarUpdates.api.ts'
  41. import type { ApolloCache, NormalizedCacheObject } from '@apollo/client/core'
  42. const { user } = storeToRefs(useSessionStore())
  43. const { breadcrumbItems } = useBreadcrumb(__('Avatar'))
  44. const { notify } = useNotifications()
  45. const application = useApplicationStore()
  46. const apiUrl = String(application.config.api_path)
  47. const { isTouchDevice } = useTouchDevice()
  48. const avatarListQuery = new QueryHandler(useUserCurrentAvatarListQuery())
  49. const avatarListQueryResult = avatarListQuery.result()
  50. const avatarListQueryLoading = avatarListQuery.loading()
  51. avatarListQuery.subscribeToMore<
  52. UserCurrentAvatarUpdatesSubscriptionVariables,
  53. UserCurrentAvatarUpdatesSubscription
  54. >({
  55. document: UserCurrentAvatarUpdatesDocument,
  56. variables: {
  57. userId: user.value?.id || '',
  58. },
  59. updateQuery: (prev, { subscriptionData }) => {
  60. if (!subscriptionData.data?.userCurrentAvatarUpdates.avatars) {
  61. return null as unknown as UserCurrentAvatarListQuery
  62. }
  63. return {
  64. userCurrentAvatarList:
  65. subscriptionData.data.userCurrentAvatarUpdates.avatars,
  66. }
  67. },
  68. })
  69. const currentAvatars = computed(() => {
  70. return avatarListQueryResult.value?.userCurrentAvatarList || []
  71. })
  72. const currentDefaultAvatar = computed(() => {
  73. return currentAvatars.value.find((avatar) => avatar.default)
  74. })
  75. const fileUploadElement = useTemplateRef('file-upload')
  76. const cameraFlyout = useFlyout({
  77. name: 'avatar-camera-capture',
  78. component: () =>
  79. import('../components/PersonalSettingAvatarCameraFlyout.vue'),
  80. })
  81. const cropImageFlyout = useFlyout({
  82. name: 'avatar-file-upload',
  83. component: () =>
  84. import('../components/PersonalSettingAvatarCropImageFlyout.vue'),
  85. })
  86. const modifyDefaultAvatarCache = (
  87. cache: ApolloCache<NormalizedCacheObject>,
  88. avatar: Avatar | undefined,
  89. newValue: boolean,
  90. ) => {
  91. if (!avatar) return
  92. cache.modify({
  93. id: cache.identify(avatar),
  94. fields: {
  95. default() {
  96. return newValue
  97. },
  98. },
  99. })
  100. }
  101. const storeAvatar = (image: ImageFileData) => {
  102. if (!image) return
  103. const addAvatarMutation = new MutationHandler(
  104. useUserCurrentAvatarAddMutation({
  105. variables: {
  106. images: {
  107. original: image,
  108. resized: {
  109. name: 'resized_avatar.png',
  110. type: 'image/png',
  111. content: image.content,
  112. },
  113. },
  114. },
  115. update: (cache, { data }) => {
  116. if (!data) return
  117. const { userCurrentAvatarAdd } = data
  118. if (!userCurrentAvatarAdd?.avatar) return
  119. const newIdPresent = currentAvatars.value.find((avatar) => {
  120. return avatar.id === userCurrentAvatarAdd.avatar?.id
  121. })
  122. if (newIdPresent) return
  123. modifyDefaultAvatarCache(cache, currentDefaultAvatar.value, false)
  124. let existingAvatars = cache.readQuery<UserCurrentAvatarListQuery>({
  125. query: UserCurrentAvatarListDocument,
  126. })
  127. existingAvatars = {
  128. ...existingAvatars,
  129. userCurrentAvatarList: [
  130. ...(existingAvatars?.userCurrentAvatarList || []),
  131. userCurrentAvatarAdd.avatar,
  132. ],
  133. }
  134. cache.writeQuery({
  135. query: UserCurrentAvatarListDocument,
  136. data: existingAvatars,
  137. })
  138. },
  139. }),
  140. {
  141. errorNotificationMessage: __('The avatar could not be uploaded.'),
  142. },
  143. )
  144. addAvatarMutation.send().then((data) => {
  145. if (data?.userCurrentAvatarAdd?.avatar) {
  146. if (user.value) {
  147. user.value.image = data.userCurrentAvatarAdd.avatar.imageHash
  148. }
  149. notify({
  150. id: 'avatar-upload-success',
  151. type: NotificationTypes.Success,
  152. message: __('Your avatar has been uploaded.'),
  153. })
  154. }
  155. })
  156. }
  157. const addAvatarByUpload = () => {
  158. fileUploadElement.value?.click()
  159. }
  160. const addAvatarByCamera = () => {
  161. cameraFlyout.open({
  162. onAvatarCaptured: (image: ImageFileData) => {
  163. storeAvatar(image)
  164. },
  165. })
  166. }
  167. const loadAvatar = async (input: HTMLInputElement | null) => {
  168. const files = input?.files
  169. if (!files) return
  170. const [avatar] = await convertFileList(files)
  171. cropImageFlyout.open({
  172. image: avatar,
  173. onImageCropped: (image: ImageFileData) => storeAvatar(image),
  174. })
  175. // Reset input value to allow selecting the same file again
  176. input.value = ''
  177. }
  178. const selectAvatar = (avatar: Avatar) => {
  179. // Update the cache already before the
  180. const { cache } = getApolloClient()
  181. const oldDefaultAvatar = currentDefaultAvatar.value
  182. modifyDefaultAvatarCache(cache, oldDefaultAvatar, false)
  183. modifyDefaultAvatarCache(cache, avatar, true)
  184. const accountAvatarSelectMutation = new MutationHandler(
  185. useUserCurrentAvatarSelectMutation(() => ({
  186. variables: { id: avatar.id },
  187. })),
  188. {
  189. errorNotificationMessage: __('The avatar could not be selected.'),
  190. },
  191. )
  192. accountAvatarSelectMutation
  193. .send()
  194. .then(() => {
  195. notify({
  196. id: 'avatar-select-success',
  197. type: NotificationTypes.Success,
  198. message: __('Your avatar has been changed.'),
  199. })
  200. })
  201. .catch(() => {
  202. // Reset the cache again if the mutation fails.
  203. modifyDefaultAvatarCache(cache, oldDefaultAvatar, true)
  204. modifyDefaultAvatarCache(cache, avatar, false)
  205. })
  206. }
  207. const deleteAvatar = (avatar: Avatar) => {
  208. const accountAvatarDeleteMutation = new MutationHandler(
  209. useUserCurrentAvatarDeleteMutation(() => ({
  210. variables: { id: avatar.id },
  211. update(cache) {
  212. if (avatar.default) {
  213. modifyDefaultAvatarCache(cache, currentAvatars.value[0], true)
  214. }
  215. cache.evict({ id: cache.identify(avatar) })
  216. cache.gc()
  217. },
  218. })),
  219. {
  220. errorNotificationMessage: __('The avatar could not be deleted.'),
  221. },
  222. )
  223. accountAvatarDeleteMutation.send().then(() => {
  224. notify({
  225. id: 'avatar-delete-success',
  226. type: NotificationTypes.Success,
  227. message: __('Your avatar has been deleted.'),
  228. })
  229. })
  230. }
  231. const { waitForVariantConfirmation } = useConfirmation()
  232. const confirmDeleteAvatar = async (avatar: Avatar) => {
  233. const confirmed = await waitForVariantConfirmation('delete')
  234. if (confirmed) deleteAvatar(avatar)
  235. }
  236. const avatarButtonClasses = [
  237. 'cursor-pointer',
  238. '-:outline-transparent',
  239. 'hover:-:outline-blue-900',
  240. 'rounded-full',
  241. 'outline',
  242. 'outline-3',
  243. 'focus:outline-blue-800',
  244. 'hover:focus:outline-blue-800',
  245. ]
  246. const activeAvatarButtonClass = (active: boolean) => {
  247. return {
  248. 'outline-blue-800 hover:outline-blue-800': active,
  249. }
  250. }
  251. </script>
  252. <template>
  253. <LayoutContent :breadcrumb-items="breadcrumbItems" width="narrow">
  254. <CommonLoader :loading="avatarListQueryLoading">
  255. <div class="mb-4">
  256. <CommonLabel class="!mt-0.5 mb-1 !block"
  257. >{{ $t('Your avatar') }}
  258. </CommonLabel>
  259. <div class="rounded-lg bg-blue-200 dark:bg-gray-700">
  260. <div class="flex flex-row flex-wrap gap-2.5 p-2.5">
  261. <template v-for="avatar in currentAvatars" :key="avatar.id">
  262. <button
  263. v-if="avatar.initial && user"
  264. :aria-label="$t('Select this avatar')"
  265. :class="[
  266. ...avatarButtonClasses,
  267. activeAvatarButtonClass(avatar.default),
  268. ]"
  269. @click.stop="avatar.default ? void 0 : selectAvatar(avatar)"
  270. >
  271. <CommonUserAvatar
  272. :class="{ 'avatar-selected': avatar.default }"
  273. :entity="user"
  274. class="!flex border-neutral-100 dark:border-gray-900"
  275. size="large"
  276. initials-only
  277. personal
  278. no-indicator
  279. no-muted
  280. />
  281. </button>
  282. <div
  283. v-else-if="avatar.imageHash"
  284. class="group/avatar relative flex"
  285. >
  286. <button
  287. :aria-label="$t('Select this avatar')"
  288. :class="[
  289. ...avatarButtonClasses,
  290. activeAvatarButtonClass(avatar.default),
  291. ]"
  292. @click.stop="avatar.default ? void 0 : selectAvatar(avatar)"
  293. >
  294. <CommonAvatar
  295. :class="{ 'avatar-selected': avatar.default }"
  296. :image="`${apiUrl}/users/image/${avatar.imageHash}`"
  297. class="!flex border-neutral-100 dark:border-gray-900"
  298. size="large"
  299. >
  300. </CommonAvatar>
  301. </button>
  302. <CommonButton
  303. v-if="avatar.deletable"
  304. :aria-label="$t('Delete this avatar')"
  305. :class="{ 'opacity-0 transition-opacity': !isTouchDevice }"
  306. class="absolute -end-2 -top-1 text-white focus:opacity-100 group-hover/avatar:opacity-100"
  307. icon="x-lg"
  308. size="small"
  309. variant="remove"
  310. @click.stop="confirmDeleteAvatar(avatar)"
  311. />
  312. </div>
  313. </template>
  314. </div>
  315. <CommonDivider padding />
  316. <div class="w-full p-1 text-center">
  317. <input
  318. ref="file-upload"
  319. :accept="allowedImageTypesString()"
  320. aria-hidden="true"
  321. class="hidden"
  322. data-test-id="fileUploadInput"
  323. type="file"
  324. @change="loadAvatar(fileUploadElement)"
  325. />
  326. <CommonButton
  327. class="m-1"
  328. size="medium"
  329. prefix-icon="image"
  330. @click="addAvatarByUpload"
  331. >
  332. {{ $t('Upload') }}
  333. </CommonButton>
  334. <CommonButton
  335. class="m-1"
  336. size="medium"
  337. prefix-icon="camera"
  338. @click="addAvatarByCamera"
  339. >
  340. {{ $t('Camera') }}
  341. </CommonButton>
  342. </div>
  343. </div>
  344. </div>
  345. </CommonLoader>
  346. </LayoutContent>
  347. </template>