]> source.dussan.org Git - nextcloud-server.git/commitdiff
feat(comments): Use activity tab to mount comments sidebar section if available
authorFerdinand Thiessen <opensource@fthiessen.de>
Tue, 14 Nov 2023 17:30:12 +0000 (18:30 +0100)
committerFerdinand Thiessen <opensource@fthiessen.de>
Thu, 16 Nov 2023 10:54:52 +0000 (11:54 +0100)
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
apps/comments/lib/Listener/LoadSidebarScripts.php
apps/comments/src/comments-activity-tab.ts [new file with mode: 0644]
apps/comments/src/comments-tab.js
apps/comments/src/components/Comment.vue
apps/comments/src/mixins/CommentMixin.js
apps/comments/src/mixins/CommentView.ts [new file with mode: 0644]
apps/comments/src/services/GetComments.ts
apps/comments/src/views/ActivityCommentAction.vue [new file with mode: 0644]
apps/comments/src/views/ActivityCommentEntry.vue [new file with mode: 0644]
apps/comments/src/views/Comments.vue

index a77cd4e0af376ed7b95fe6f63c2fe70f31b774a9..39c81c03ad10665cf4dd24f5572ac14ef3f45f99 100644 (file)
@@ -28,6 +28,8 @@ namespace OCA\Comments\Listener;
 
 use OCA\Comments\AppInfo\Application;
 use OCA\Files\Event\LoadSidebar;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Services\IInitialState;
 use OCP\Comments\ICommentsManager;
 use OCP\EventDispatcher\Event;
 use OCP\EventDispatcher\IEventListener;
@@ -36,6 +38,8 @@ use OCP\Util;
 class LoadSidebarScripts implements IEventListener {
        public function __construct(
                private ICommentsManager $commentsManager,
+               private IInitialState $initialState,
+               private IAppManager $appManager,
        ) {
        }
 
@@ -46,6 +50,8 @@ class LoadSidebarScripts implements IEventListener {
 
                $this->commentsManager->load();
 
+               $this->initialState->provideInitialState('activityEnabled', $this->appManager->isEnabledForUser('activity'));
+
                // TODO: make sure to only include the sidebar script when
                // we properly split it between files list and sidebar
                Util::addScript(Application::APP_ID, 'comments');
diff --git a/apps/comments/src/comments-activity-tab.ts b/apps/comments/src/comments-activity-tab.ts
new file mode 100644 (file)
index 0000000..30c1e38
--- /dev/null
@@ -0,0 +1,85 @@
+/**
+ * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import moment from '@nextcloud/moment'
+import Vue from 'vue'
+import logger from './logger.js'
+import { getComments } from './services/GetComments.js'
+
+let ActivityTabPluginView
+let ActivityTabPluginInstance
+
+/**
+ * Register the comments plugins for the Activity sidebar
+ */
+export function registerCommentsPlugins() {
+       window.OCA.Activity.registerSidebarAction({
+               mount: async (el, { context, fileInfo, reload }) => {
+                       if (!ActivityTabPluginView) {
+                               const { default: ActivityCommmentAction } = await import('./views/ActivityCommentAction.vue')
+                               ActivityTabPluginView = Vue.extend(ActivityCommmentAction)
+                       }
+                       ActivityTabPluginInstance = new ActivityTabPluginView({
+                               parent: context,
+                               propsData: {
+                                       reloadCallback: reload,
+                                       ressourceId: fileInfo.id,
+                               },
+                       })
+                       ActivityTabPluginInstance.$mount(el)
+                       logger.info('Comments plugin mounted in Activity sidebar action', { fileInfo })
+               },
+               unmount: () => {
+                       // destroy previous instance if available
+                       if (ActivityTabPluginInstance) {
+                               ActivityTabPluginInstance.$destroy()
+                       }
+               },
+       })
+
+       window.OCA.Activity.registerSidebarEntries(async ({ fileInfo, limit, offset }) => {
+               const { data: comments } = await getComments({ commentsType: 'files', ressourceId: fileInfo.id }, { limit, offset })
+               logger.debug('Loaded comments', { fileInfo, comments })
+               const { default: CommentView } = await import('./views/ActivityCommentEntry.vue')
+               const CommentsViewObject = Vue.extend(CommentView)
+
+               return comments.map((comment) => ({
+                       timestamp: moment(comment.props.creationDateTime).toDate().getTime(),
+                       mount(element, { context, reload }) {
+                               this._CommentsViewInstance = new CommentsViewObject({
+                                       parent: context,
+                                       propsData: {
+                                               comment,
+                                               ressourceId: fileInfo.id,
+                                               reloadCallback: reload,
+                                       },
+                               })
+                               this._CommentsViewInstance.$mount(element)
+                       },
+                       unmount() {
+                               this._CommentsViewInstance.$destroy()
+                       },
+               }))
+       })
+
+       window.OCA.Activity.registerSidebarFilter((activity) => activity.type !== 'comments')
+       logger.info('Comments plugin registered for Activity sidebar action')
+}
index 121b8d686f4e29a9aefee650a8aac5923a6512be..1a367cc18ee8a58c42b2f61322df70a718c4a27e 100644 (file)
 
 // eslint-disable-next-line n/no-missing-import, import/no-unresolved
 import MessageReplyText from '@mdi/svg/svg/message-reply-text.svg?raw'
+import { getRequestToken } from '@nextcloud/auth'
+import { loadState } from '@nextcloud/initial-state'
+import { registerCommentsPlugins } from './comments-activity-tab.ts'
 
-// Init Comments tab component
-let TabInstance = null
-const commentTab = new OCA.Files.Sidebar.Tab({
-       id: 'comments',
-       name: t('comments', 'Comments'),
-       iconSvg: MessageReplyText,
+// @ts-expect-error __webpack_nonce__ is injected by webpack
+__webpack_nonce__ = btoa(getRequestToken())
 
-       async mount(el, fileInfo, context) {
-               if (TabInstance) {
+if (loadState('comments', 'activityEnabled', false) && OCA?.Activity?.registerSidebarAction !== undefined) {
+       // Do not mount own tab but mount into activity
+       window.addEventListener('DOMContentLoaded', function() {
+               registerCommentsPlugins()
+       })
+} else {
+       // Init Comments tab component
+       let TabInstance = null
+       const commentTab = new OCA.Files.Sidebar.Tab({
+               id: 'comments',
+               name: t('comments', 'Comments'),
+               iconSvg: MessageReplyText,
+
+               async mount(el, fileInfo, context) {
+                       if (TabInstance) {
+                               TabInstance.$destroy()
+                       }
+                       TabInstance = new OCA.Comments.View('files', {
+                               // Better integration with vue parent component
+                               parent: context,
+                       })
+                       // Only mount after we have all the info we need
+                       await TabInstance.update(fileInfo.id)
+                       TabInstance.$mount(el)
+               },
+               update(fileInfo) {
+                       TabInstance.update(fileInfo.id)
+               },
+               destroy() {
                        TabInstance.$destroy()
-               }
-               TabInstance = new OCA.Comments.View('files', {
-                       // Better integration with vue parent component
-                       parent: context,
-               })
-               // Only mount after we have all the info we need
-               await TabInstance.update(fileInfo.id)
-               TabInstance.$mount(el)
-       },
-       update(fileInfo) {
-               TabInstance.update(fileInfo.id)
-       },
-       destroy() {
-               TabInstance.$destroy()
-               TabInstance = null
-       },
-       scrollBottomReached() {
-               TabInstance.onScrollBottomReached()
-       },
-})
+                       TabInstance = null
+               },
+               scrollBottomReached() {
+                       TabInstance.onScrollBottomReached()
+               },
+       })
 
-window.addEventListener('DOMContentLoaded', function() {
-       if (OCA.Files && OCA.Files.Sidebar) {
-               OCA.Files.Sidebar.registerTab(commentTab)
-       }
-})
+       window.addEventListener('DOMContentLoaded', function() {
+               if (OCA.Files && OCA.Files.Sidebar) {
+                       OCA.Files.Sidebar.registerTab(commentTab)
+               }
+       })
+}
index e8ae9a88e771d8546465e19636365db27debcc71..a5b72efb74b8350b2a3096f5f85eaf4cc7325671 100644 (file)
 
 <script>
 import { getCurrentUser } from '@nextcloud/auth'
+import { translate as t } from '@nextcloud/l10n'
 import moment from '@nextcloud/moment'
 
 import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
@@ -235,6 +236,8 @@ export default {
        },
 
        methods: {
+               t,
+
                /**
                 * Update local Message on outer change
                 *
@@ -279,7 +282,7 @@ $comment-padding: 10px;
 
 .comment {
        display: flex;
-       gap: 16px;
+       gap: 8px;
        padding: 5px $comment-padding;
 
        &__side {
index 545625ab97ee871ae4b6773009951b19032501c4..bcb72af23156fcdf25391050f8e6f664aac9d9da 100644 (file)
  *
  */
 
+import { showError, showUndo, TOAST_UNDO_TIMEOUT } from '@nextcloud/dialogs'
 import NewComment from '../services/NewComment.js'
 import DeleteComment from '../services/DeleteComment.js'
 import EditComment from '../services/EditComment.js'
-import { showError, showUndo, TOAST_UNDO_TIMEOUT } from '@nextcloud/dialogs'
+import logger from '../logger.js'
 
 export default {
        props: {
@@ -46,6 +47,7 @@ export default {
                        deleted: false,
                        editing: false,
                        loading: false,
+                       commentsType: 'files',
                }
        },
 
@@ -63,7 +65,7 @@ export default {
                        this.loading = true
                        try {
                                await EditComment(this.commentsType, this.ressourceId, this.id, message)
-                               this.logger.debug('Comment edited', { commentsType: this.commentsType, ressourceId: this.ressourceId, id: this.id, message })
+                               logger.debug('Comment edited', { commentsType: this.commentsType, ressourceId: this.ressourceId, id: this.id, message })
                                this.$emit('update:message', message)
                                this.editing = false
                        } catch (error) {
@@ -86,7 +88,7 @@ export default {
                async onDelete() {
                        try {
                                await DeleteComment(this.commentsType, this.ressourceId, this.id)
-                               this.logger.debug('Comment deleted', { commentsType: this.commentsType, ressourceId: this.ressourceId, id: this.id })
+                               logger.debug('Comment deleted', { commentsType: this.commentsType, ressourceId: this.ressourceId, id: this.id })
                                this.$emit('delete', this.id)
                        } catch (error) {
                                showError(t('comments', 'An error occurred while trying to delete the comment'))
@@ -100,7 +102,7 @@ export default {
                        this.loading = true
                        try {
                                const newComment = await NewComment(this.commentsType, this.ressourceId, message)
-                               this.logger.debug('New comment posted', { commentsType: this.commentsType, ressourceId: this.ressourceId, newComment })
+                               logger.debug('New comment posted', { commentsType: this.commentsType, ressourceId: this.ressourceId, newComment })
                                this.$emit('new', newComment)
 
                                // Clear old content
diff --git a/apps/comments/src/mixins/CommentView.ts b/apps/comments/src/mixins/CommentView.ts
new file mode 100644 (file)
index 0000000..9cd1490
--- /dev/null
@@ -0,0 +1,68 @@
+import axios from '@nextcloud/axios'
+import { getCurrentUser } from '@nextcloud/auth'
+import { loadState } from '@nextcloud/initial-state'
+import { generateOcsUrl } from '@nextcloud/router'
+import { defineComponent } from 'vue'
+
+export default defineComponent({
+       props: {
+               ressourceId: {
+                       type: Number,
+                       required: true,
+               },
+       },
+       data() {
+               return {
+                       editorData: {
+                               actorDisplayName: getCurrentUser()!.displayName as string,
+                               actorId: getCurrentUser()!.uid as string,
+                               key: 'editor',
+                       },
+                       userData: {},
+               }
+       },
+       methods: {
+               /**
+                * Autocomplete @mentions
+                *
+                * @param {string} search the query
+                * @param {Function} callback the callback to process the results with
+                */
+               async autoComplete(search, callback) {
+                       const { data } = await axios.get(generateOcsUrl('core/autocomplete/get'), {
+                               params: {
+                                       search,
+                                       itemType: 'files',
+                                       itemId: this.ressourceId,
+                                       sorter: 'commenters|share-recipients',
+                                       limit: loadState('comments', 'maxAutoCompleteResults'),
+                               },
+                       })
+                       // Save user data so it can be used by the editor to replace mentions
+                       data.ocs.data.forEach(user => { this.userData[user.id] = user })
+                       return callback(Object.values(this.userData))
+               },
+
+               /**
+                * Make sure we have all mentions as Array of objects
+                *
+                * @param mentions the mentions list
+                */
+               // eslint-disable-next-line @typescript-eslint/no-explicit-any
+               genMentionsData(mentions: any[]): Record<string, object> {
+                       Object.values(mentions)
+                               .flat()
+                               .forEach(mention => {
+                                       this.userData[mention.mentionId] = {
+                                               // TODO: support groups
+                                               icon: 'icon-user',
+                                               id: mention.mentionId,
+                                               label: mention.mentionDisplayName,
+                                               source: 'users',
+                                               primary: getCurrentUser()?.uid === mention.mentionId,
+                                       }
+                               })
+                       return this.userData
+               },
+       },
+})
index d74e92bce68c6d732d3b9d5c87ccd2577e10f2c2..2ca725c2ae6303a658c7ed0a729d09be751a18a1 100644 (file)
@@ -20,7 +20,7 @@
  *
  */
 
-import { parseXML, type DAVResult, type FileStat } from 'webdav'
+import { parseXML, type DAVResult, type FileStat, type ResponseDataDetailed } from 'webdav'
 
 // https://github.com/perry-mitchell/webdav-client/issues/339
 import { processResponsePayload } from '../../../../node_modules/webdav/dist/node/response.js'
@@ -37,11 +37,13 @@ export const DEFAULT_LIMIT = 20
  * @param {number} data.ressourceId the ressource ID
  * @param {object} [options] optional options for axios
  * @param {number} [options.offset] the pagination offset
- * @return {object[]} the comments list
+ * @param {number} [options.limit] the pagination limit, defaults to 20
+ * @param {Date} [options.datetime] optional date to query
+ * @return {{data: object[]}} the comments list
  */
-export const getComments = async function({ commentsType, ressourceId }, options: { offset: number }) {
+export const getComments = async function({ commentsType, ressourceId }, options: { offset: number, limit?: number, datetime?: Date }) {
        const ressourcePath = ['', commentsType, ressourceId].join('/')
-
+       const datetime = options.datetime ? `<oc:datetime>${options.datetime.toISOString()}</oc:datetime>` : ''
        const response = await client.customRequest(ressourcePath, Object.assign({
                method: 'REPORT',
                data: `<?xml version="1.0"?>
@@ -50,15 +52,16 @@ export const getComments = async function({ commentsType, ressourceId }, options
                                xmlns:oc="http://owncloud.org/ns"
                                xmlns:nc="http://nextcloud.org/ns"
                                xmlns:ocs="http://open-collaboration-services.org/ns">
-                               <oc:limit>${DEFAULT_LIMIT}</oc:limit>
+                               <oc:limit>${options.limit ?? DEFAULT_LIMIT}</oc:limit>
                                <oc:offset>${options.offset || 0}</oc:offset>
+                               ${datetime}
                        </oc:filter-comments>`,
        }, options))
 
        const responseData = await response.text()
        const result = await parseXML(responseData)
        const stat = getDirectoryFiles(result, true)
-       return processResponsePayload(response, stat, true)
+       return processResponsePayload(response, stat, true) as ResponseDataDetailed<FileStat[]>
 }
 
 // https://github.com/perry-mitchell/webdav-client/blob/8d9694613c978ce7404e26a401c39a41f125f87f/source/operations/directoryContents.ts
diff --git a/apps/comments/src/views/ActivityCommentAction.vue b/apps/comments/src/views/ActivityCommentAction.vue
new file mode 100644 (file)
index 0000000..96edaf6
--- /dev/null
@@ -0,0 +1,70 @@
+<!--
+  - @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
+  -
+  - @author Ferdinand Thiessen <opensource@fthiessen.de>
+  -
+  - @license AGPL-3.0-or-later
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+       <Comment v-bind="editorData"
+               :auto-complete="autoComplete"
+               :user-data="userData"
+               :editor="true"
+               :ressource-id="ressourceId"
+               class="comments-action"
+               @new="onNewComment" />
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import Comment from '../components/Comment.vue'
+import CommentView from '../mixins/CommentView.js'
+import logger from '../logger'
+import { showError } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+
+export default defineComponent({
+       components: {
+               Comment,
+       },
+       mixins: [CommentView],
+       props: {
+               reloadCallback: {
+                       type: Function,
+                       required: true,
+               },
+       },
+       methods: {
+               onNewComment() {
+                       try {
+                               // just force reload
+                               this.reloadCallback()
+                       } catch (e) {
+                               showError(t('comments', 'Could not reload comments'))
+                               logger.debug(e)
+                       }
+               },
+       },
+})
+</script>
+
+<style scoped>
+.comments-action {
+       padding: 0;
+}
+</style>
diff --git a/apps/comments/src/views/ActivityCommentEntry.vue b/apps/comments/src/views/ActivityCommentEntry.vue
new file mode 100644 (file)
index 0000000..21c600d
--- /dev/null
@@ -0,0 +1,86 @@
+<!--
+  - @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
+  -
+  - @author Ferdinand Thiessen <opensource@fthiessen.de>
+  -
+  - @license AGPL-3.0-or-later
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+       <Comment ref="comment"
+               tag="li"
+               v-bind="comment.props"
+               :auto-complete="autoComplete"
+               :message="commentMessage"
+               :ressource-id="ressourceId"
+               :user-data="genMentionsData(comment.props.mentions)"
+               class="comments-activity"
+               @delete="reloadCallback()" />
+</template>
+
+<script lang="ts">
+import { translate as t } from '@nextcloud/l10n'
+
+import Comment from '../components/Comment.vue'
+import CommentView from '../mixins/CommentView'
+
+export default {
+       name: 'ActivityCommentEntry',
+
+       components: {
+               Comment,
+       },
+
+       mixins: [CommentView],
+       props: {
+               comment: {
+                       type: Object,
+                       required: true,
+               },
+               reloadCallback: {
+                       type: Function,
+                       required: true,
+               },
+       },
+
+       data() {
+               return {
+                       commentMessage: '',
+               }
+       },
+
+       watch: {
+               comment() {
+                       this.commentMessage = this.comment.props.message
+               },
+       },
+
+       mounted() {
+               this.commentMessage = this.comment.props.message
+       },
+
+       methods: {
+               t,
+       },
+}
+</script>
+
+<style scoped>
+.comments-activity {
+       padding: 0;
+}
+</style>
index 93e9031df5a88b7ac61c88c57ef7c910719a3ec7..7a36823299e9a6699c4c8d4cc61681ed27713529 100644 (file)
 </template>
 
 <script>
-import { generateOcsUrl } from '@nextcloud/router'
-import { getCurrentUser } from '@nextcloud/auth'
-import { loadState } from '@nextcloud/initial-state'
 import { showError } from '@nextcloud/dialogs'
-import axios from '@nextcloud/axios'
+import { translate as t } from '@nextcloud/l10n'
 import VTooltip from 'v-tooltip'
 import Vue from 'vue'
 import VueObserveVisibility from 'vue-observe-visibility'
@@ -101,6 +98,7 @@ import Comment from '../components/Comment.vue'
 import { getComments, DEFAULT_LIMIT } from '../services/GetComments.ts'
 import cancelableRequest from '../utils/cancelableRequest.js'
 import { markCommentsAsRead } from '../services/ReadComments.ts'
+import CommentView from '../mixins/CommentView'
 
 Vue.use(VTooltip)
 Vue.use(VueObserveVisibility)
@@ -109,7 +107,6 @@ export default {
        name: 'Comments',
 
        components: {
-               // Avatar,
                Comment,
                NcEmptyContent,
                NcButton,
@@ -118,6 +115,8 @@ export default {
                AlertCircleOutlineIcon,
        },
 
+       mixins: [CommentView],
+
        data() {
                return {
                        error: '',
@@ -130,12 +129,6 @@ export default {
 
                        cancelRequest: () => {},
 
-                       editorData: {
-                               actorDisplayName: getCurrentUser().displayName,
-                               actorId: getCurrentUser().uid,
-                               key: 'editor',
-                       },
-
                        Comment,
                        userData: {},
                }
@@ -151,6 +144,8 @@ export default {
        },
 
        methods: {
+               t,
+
                async onVisibilityChange(isVisible) {
                        if (isVisible) {
                                try {
@@ -188,28 +183,6 @@ export default {
                        this.getComments()
                },
 
-               /**
-                * Make sure we have all mentions as Array of objects
-                *
-                * @param {any[]} mentions the mentions list
-                * @return {Record<string, object>}
-                */
-               genMentionsData(mentions) {
-                       Object.values(mentions)
-                               .flat()
-                               .forEach(mention => {
-                                       this.userData[mention.mentionId] = {
-                                               // TODO: support groups
-                                               icon: 'icon-user',
-                                               id: mention.mentionId,
-                                               label: mention.mentionDisplayName,
-                                               source: 'users',
-                                               primary: getCurrentUser().uid === mention.mentionId,
-                                       }
-                               })
-                       return this.userData
-               },
-
                /**
                 * Get the existing shares infos
                 */
@@ -255,27 +228,6 @@ export default {
                        }
                },
 
-               /**
-                * Autocomplete @mentions
-                *
-                * @param {string} search the query
-                * @param {Function} callback the callback to process the results with
-                */
-               async autoComplete(search, callback) {
-                       const results = await axios.get(generateOcsUrl('core/autocomplete/get'), {
-                               params: {
-                                       search,
-                                       itemType: 'files',
-                                       itemId: this.ressourceId,
-                                       sorter: 'commenters|share-recipients',
-                                       limit: loadState('comments', 'maxAutoCompleteResults'),
-                               },
-                       })
-                       // Save user data so it can be used by the editor to replace mentions
-                       results.data.ocs.data.forEach(user => { this.userData[user.id] = user })
-                       return callback(Object.values(this.userData))
-               },
-
                /**
                 * Add newly created comment to the list
                 *