diff --git a/README.md b/README.md index 45c06e1b..b8435ab7 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ It is strongly focused on the following points: It is released as a **browser webapp** and also packaged as an **cross-platform desktop application** (Mac, Windows, and Linux). +The Electron code isn't hosted here anymore, and you'll find it [here](https://github.com/NicolasConstant/sengi-electron). + ## Official project page [Discover Sengi](https://nicolasconstant.github.io/sengi/) diff --git a/package.json b/package.json index 926aba76..824fae64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sengi", - "version": "1.2.0", + "version": "1.3.0", "license": "AGPL-3.0-or-later", "main": "main-electron.js", "description": "A multi-account desktop client for Mastodon and Pleroma", diff --git a/src/app/components/create-status/create-status.component.html b/src/app/components/create-status/create-status.component.html index 3816e118..e6a5b1a2 100644 --- a/src/app/components/create-status/create-status.component.html +++ b/src/app/components/create-status/create-status.component.html @@ -8,7 +8,9 @@ diff --git a/src/app/components/create-status/create-status.component.ts b/src/app/components/create-status/create-status.component.ts index 456b8b0e..9ef54b66 100644 --- a/src/app/components/create-status/create-status.component.ts +++ b/src/app/components/create-status/create-status.component.ts @@ -89,6 +89,7 @@ export class CreateStatusComponent implements OnInit, OnDestroy { this.isEditing = true; this.editingStatusId = value.status.id; this.redraftedStatus = value; + this.mediaService.loadMedia(value.status.media_attachments); } } @@ -537,7 +538,7 @@ export class CreateStatusComponent implements OnInit, OnDestroy { return false; } - onSubmit(): boolean { + async onSubmit(): Promise { if (this.isSending || this.mentionTooFarAwayError) return false; this.isSending = true; @@ -558,9 +559,10 @@ export class CreateStatusComponent implements OnInit, OnDestroy { break; } - const mediaAttachments = this.mediaService.mediaSubject.value.map(x => x.attachment); - const acc = this.toolsService.getSelectedAccounts()[0]; + + const mediaAttachments = (await this.mediaService.retrieveUpToDateMedia(acc)).map(x => x.attachment); + let usableStatus: Promise; if (this.statusReplyingToWrapper) { usableStatus = this.toolsService.getStatusUsableByAccount(acc, this.statusReplyingToWrapper); @@ -628,7 +630,7 @@ export class CreateStatusComponent implements OnInit, OnDestroy { let postPromise: Promise; if (this.isEditing) { - postPromise = this.mastodonService.editStatus(account, editingStatusId, s, visibility, title, inReplyToId, attachments.map(x => x.id), poll, scheduledAt); + postPromise = this.mastodonService.editStatus(account, editingStatusId, s, visibility, title, inReplyToId, attachments, poll, scheduledAt); } else { postPromise = this.mastodonService.postNewStatus(account, s, visibility, title, inReplyToId, attachments.map(x => x.id), poll, scheduledAt); } diff --git a/src/app/components/create-status/media/media.component.html b/src/app/components/create-status/media/media.component.html index 747b43f5..de6c6465 100644 --- a/src/app/components/create-status/media/media.component.html +++ b/src/app/components/create-status/media/media.component.html @@ -1,8 +1,8 @@ - + - diff --git a/src/app/components/create-status/media/media.component.ts b/src/app/components/create-status/media/media.component.ts index a09d0a0e..456606ea 100644 --- a/src/app/components/create-status/media/media.component.ts +++ b/src/app/components/create-status/media/media.component.ts @@ -56,4 +56,13 @@ export class MediaComponent implements OnInit, OnDestroy { this.mediaService.update(account, media); return false; } + + getName(media: MediaWrapper): string { + if(media && media.file && media.file.name){ + return media.file.name; + } + if(media.attachment && media.attachment.description){ + return media.attachment.description; + } + } } diff --git a/src/app/components/floating-column/manage-account/mentions/mentions.component.ts b/src/app/components/floating-column/manage-account/mentions/mentions.component.ts index e1ca1c8d..801f72d2 100644 --- a/src/app/components/floating-column/manage-account/mentions/mentions.component.ts +++ b/src/app/components/floating-column/manage-account/mentions/mentions.component.ts @@ -82,7 +82,7 @@ export class MentionsComponent extends TimelineBase { } protected getNextStatuses(): Promise { - return this.mastodonService.getNotifications(this.account, ['follow', 'favourite', 'reblog', 'poll', 'move'], this.lastId) + return this.mastodonService.getNotifications(this.account, ['follow', 'favourite', 'reblog', 'poll', 'move', 'update'], this.lastId) .then((result: Notification[]) => { const statuses = result.map(x => x.status); diff --git a/src/app/components/floating-column/manage-account/notifications/notifications.component.ts b/src/app/components/floating-column/manage-account/notifications/notifications.component.ts index da9313d0..f766ca70 100644 --- a/src/app/components/floating-column/manage-account/notifications/notifications.component.ts +++ b/src/app/components/floating-column/manage-account/notifications/notifications.component.ts @@ -101,7 +101,7 @@ export class NotificationsComponent extends BrowseBase { this.isLoading = true; this.isProcessingInfiniteScroll = true; - this.mastodonService.getNotifications(this.account.info, ['mention'], this.lastId) + this.mastodonService.getNotifications(this.account.info, ['mention', 'update'], this.lastId) .then((notifications: Notification[]) => { if (notifications.length === 0) { this.maxReached = true; @@ -168,5 +168,5 @@ export class NotificationWrapper { account: Account; target: Account; status: StatusWrapper; - type: 'mention' | 'reblog' | 'favourite' | 'follow' | 'poll' | 'follow_request' | 'move'; + type: 'mention' | 'reblog' | 'favourite' | 'follow' | 'poll' | 'follow_request' | 'move' | 'update'; } \ No newline at end of file diff --git a/src/app/components/stream/status/action-bar/status-user-context-menu/status-user-context-menu.component.html b/src/app/components/stream/status/action-bar/status-user-context-menu/status-user-context-menu.component.html index d91d0d60..3b49e8a7 100644 --- a/src/app/components/stream/status/action-bar/status-user-context-menu/status-user-context-menu.component.html +++ b/src/app/components/stream/status/action-bar/status-user-context-menu/status-user-context-menu.component.html @@ -28,11 +28,17 @@ Unmute conversation - + Mute @{{ this.username }} - + + Unmute @{{ this.username }} + + Block @{{ this.username }} + + + Unblock @{{ this.username }} Pin on profile diff --git a/src/app/components/stream/status/action-bar/status-user-context-menu/status-user-context-menu.component.ts b/src/app/components/stream/status/action-bar/status-user-context-menu/status-user-context-menu.component.ts index 85744157..f09e1cf4 100644 --- a/src/app/components/stream/status/action-bar/status-user-context-menu/status-user-context-menu.component.ts +++ b/src/app/components/stream/status/action-bar/status-user-context-menu/status-user-context-menu.component.ts @@ -4,7 +4,7 @@ import { ContextMenuComponent, ContextMenuService } from 'ngx-contextmenu'; import { Observable, Subscription } from 'rxjs'; import { Store } from '@ngxs/store'; -import { Status, Account, Results } from '../../../../../services/models/mastodon.interfaces'; +import { Status, Account, Results, Relationship } from '../../../../../services/models/mastodon.interfaces'; import { ToolsService, OpenThreadEvent, InstanceInfo } from '../../../../../services/tools.service'; import { StatusWrapper } from '../../../../../models/common.model'; import { NavigationService } from '../../../../../services/navigation.service'; @@ -31,8 +31,10 @@ export class StatusUserContextMenuComponent implements OnInit, OnDestroy { @Input() statusWrapper: StatusWrapper; @Input() displayedAccount: Account; + @Input() relationship: Relationship; @Output() browseThreadEvent = new EventEmitter(); + @Output() relationshipChanged = new EventEmitter(); @ViewChild(ContextMenuComponent) public contextMenu: ContextMenuComponent; @@ -166,37 +168,75 @@ export class StatusUserContextMenuComponent implements OnInit, OnDestroy { } muteAccount(): boolean { - this.loadedAccounts.forEach(acc => { - this.toolsService.findAccount(acc, this.fullHandle) - .then((target: Account) => { - this.mastodonService.mute(acc, target.id); - return target; - }) - .then((target: Account) => { - this.notificationService.hideAccount(target); - }) - .catch(err => { - this.notificationService.notifyHttpError(err, acc); - }); - }); + const acc = this.toolsService.getSelectedAccounts()[0]; + + this.toolsService.findAccount(acc, this.fullHandle) + .then(async (target: Account) => { + const relationship = await this.mastodonService.mute(acc, target.id); + this.relationship = relationship; + this.relationshipChanged.next(relationship); + return target; + }) + .then((target: Account) => { + this.notificationService.hideAccount(target); + }) + .catch(err => { + this.notificationService.notifyHttpError(err, acc); + }); + + return false; + } + + unmuteAccount(): boolean { + const acc = this.toolsService.getSelectedAccounts()[0]; + + this.toolsService.findAccount(acc, this.fullHandle) + .then(async (target: Account) => { + const relationship = await this.mastodonService.unmute(acc, target.id); + this.relationship = relationship; + this.relationshipChanged.next(relationship); + return target; + }) + .catch(err => { + this.notificationService.notifyHttpError(err, acc); + }); return false; } blockAccount(): boolean { - this.loadedAccounts.forEach(acc => { - this.toolsService.findAccount(acc, this.fullHandle) - .then((target: Account) => { - this.mastodonService.block(acc, target.id); - return target; - }) - .then((target: Account) => { - this.notificationService.hideAccount(target); - }) - .catch(err => { - this.notificationService.notifyHttpError(err, acc); - }); - }); + const acc = this.toolsService.getSelectedAccounts()[0]; + + this.toolsService.findAccount(acc, this.fullHandle) + .then(async (target: Account) => { + const relationship = await this.mastodonService.block(acc, target.id); + this.relationship = relationship; + this.relationshipChanged.next(relationship); + return target; + }) + .then((target: Account) => { + this.notificationService.hideAccount(target); + }) + .catch(err => { + this.notificationService.notifyHttpError(err, acc); + }); + + return false; + } + + unblockAccount(): boolean { + const acc = this.toolsService.getSelectedAccounts()[0]; + + this.toolsService.findAccount(acc, this.fullHandle) + .then(async (target: Account) => { + const relationship = await this.mastodonService.unblock(acc, target.id); + this.relationship = relationship; + this.relationshipChanged.next(relationship); + return target; + }) + .catch(err => { + this.notificationService.notifyHttpError(err, acc); + }); return false; } diff --git a/src/app/components/stream/status/attachements/attachements.component.ts b/src/app/components/stream/status/attachements/attachements.component.ts index e3f2114a..35893b69 100644 --- a/src/app/components/stream/status/attachements/attachements.component.ts +++ b/src/app/components/stream/status/attachements/attachements.component.ts @@ -30,6 +30,10 @@ export class AttachementsComponent implements OnInit { @Input('attachments') set attachments(value: Attachment[]) { + this.imageAttachments = []; + this.videoAttachments = []; + this.audioAttachments = []; + this._attachments = value; this.setAttachments(value); } diff --git a/src/app/components/stream/stream-notifications/stream-notifications.component.ts b/src/app/components/stream/stream-notifications/stream-notifications.component.ts index 2429eee7..05d202d7 100644 --- a/src/app/components/stream/stream-notifications/stream-notifications.component.ts +++ b/src/app/components/stream/stream-notifications/stream-notifications.component.ts @@ -126,7 +126,7 @@ export class StreamNotificationsComponent extends BrowseBase { this.loadMentions(userNotifications); }); - this.mastodonService.getNotifications(this.account, null, null, null, 10) + this.mastodonService.getNotifications(this.account, ['update'], null, null, 10) //FIXME: disable edition update until supported .then((notifications: Notification[]) => { this.isNotificationsLoading = false; @@ -201,7 +201,7 @@ export class StreamNotificationsComponent extends BrowseBase { this.isNotificationsLoading = true; - this.mastodonService.getNotifications(this.account, null, this.lastNotificationId) + this.mastodonService.getNotifications(this.account, ['update'], this.lastNotificationId) .then((result: Notification[]) => { if (result.length === 0) { this.notificationsMaxReached = true; @@ -235,7 +235,7 @@ export class StreamNotificationsComponent extends BrowseBase { this.isMentionsLoading = true; - this.mastodonService.getNotifications(this.account, ['follow', 'favourite', 'reblog', 'poll', 'follow_request', 'move'], this.lastMentionId) + this.mastodonService.getNotifications(this.account, ['follow', 'favourite', 'reblog', 'poll', 'follow_request', 'move', 'update'], this.lastMentionId) .then((result: Notification[]) => { if (result.length === 0) { this.mentionsMaxReached = true; diff --git a/src/app/components/stream/user-profile/user-profile.component.html b/src/app/components/stream/user-profile/user-profile.component.html index cc154f6c..50c69c55 100644 --- a/src/app/components/stream/user-profile/user-profile.component.html +++ b/src/app/components/stream/user-profile/user-profile.component.html @@ -107,7 +107,9 @@ - + diff --git a/src/app/components/stream/user-profile/user-profile.component.ts b/src/app/components/stream/user-profile/user-profile.component.ts index 3677530b..81033a7f 100644 --- a/src/app/components/stream/user-profile/user-profile.component.ts +++ b/src/app/components/stream/user-profile/user-profile.component.ts @@ -268,6 +268,10 @@ export class UserProfileComponent extends BrowseBase { this.showFloatingStatusMenu = false; this.load(this.lastAccountName); } + + relationshipChanged(relationship: Relationship){ + this.relationship = relationship; + } browseAccount(accountName: string): void { if (accountName === this.toolsService.getAccountFullHandle(this.displayedAccount)) return; diff --git a/src/app/services/instances-info.service.ts b/src/app/services/instances-info.service.ts index d7cf2aac..3d64fc5e 100644 --- a/src/app/services/instances-info.service.ts +++ b/src/app/services/instances-info.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { VisibilityEnum } from './mastodon.service'; import { MastodonWrapperService } from './mastodon-wrapper.service'; -import { Instance, Account } from './models/mastodon.interfaces'; +import { Instance, Instancev1, Instancev2, Account } from './models/mastodon.interfaces'; import { AccountInfo } from '../states/accounts.state'; @Injectable({ @@ -19,11 +19,20 @@ export class InstancesInfoService { if (!this.cachedMaxInstanceChar[instance]) { this.cachedMaxInstanceChar[instance] = this.mastodonService.getInstance(instance) .then((instance: Instance) => { - if (instance.max_toot_chars) { - return instance.max_toot_chars; + if (+instance.version.split('.')[0] >= 4) { + const instanceV2 = instance; + if (instanceV2 + && instanceV2.configuration + && instanceV2.configuration.statuses + && instanceV2.configuration.statuses.max_characters) + return instanceV2.configuration.statuses.max_characters; } else { - return this.defaultMaxChars; + const instanceV1 = instance; + if (instanceV1 && instanceV1.max_toot_chars) + return instanceV1.max_toot_chars; } + + return this.defaultMaxChars; }) .catch(() => { return this.defaultMaxChars; diff --git a/src/app/services/mastodon-wrapper.service.ts b/src/app/services/mastodon-wrapper.service.ts index b9a172c8..c7c51b88 100644 --- a/src/app/services/mastodon-wrapper.service.ts +++ b/src/app/services/mastodon-wrapper.service.ts @@ -124,10 +124,10 @@ export class MastodonWrapperService { }); } - editStatus(account: AccountInfo, statusId: string, status: string, visibility: VisibilityEnum, spoiler: string = null, in_reply_to_id: string = null, mediaIds: string[], poll: PollParameters = null, scheduled_at: string = null): Promise { + editStatus(account: AccountInfo, statusId: string, status: string, visibility: VisibilityEnum, spoiler: string = null, in_reply_to_id: string = null, attachements: Attachment[], poll: PollParameters = null, scheduled_at: string = null): Promise { return this.refreshAccountIfNeeded(account) .then((refreshedAccount: AccountInfo) => { - return this.mastodonService.editStatus(refreshedAccount, statusId, status, visibility, spoiler, in_reply_to_id, mediaIds, poll, scheduled_at); + return this.mastodonService.editStatus(refreshedAccount, statusId, status, visibility, spoiler, in_reply_to_id, attachements, poll, scheduled_at); }); } @@ -309,7 +309,7 @@ export class MastodonWrapperService { }); } - getNotifications(account: AccountInfo, excludeTypes: ('follow' | 'favourite' | 'reblog' | 'mention' | 'poll' | 'follow_request' | 'move')[] = null, maxId: string = null, sinceId: string = null, limit: number = 15): Promise { + getNotifications(account: AccountInfo, excludeTypes: ('follow' | 'favourite' | 'reblog' | 'mention' | 'poll' | 'follow_request' | 'move' | 'update')[] = null, maxId: string = null, sinceId: string = null, limit: number = 15): Promise { return this.refreshAccountIfNeeded(account) .then((refreshedAccount: AccountInfo) => { return this.mastodonService.getNotifications(refreshedAccount, excludeTypes, maxId, sinceId, limit); @@ -379,6 +379,13 @@ export class MastodonWrapperService { }); } + unmute(account: AccountInfo, accounId: number): Promise { + return this.refreshAccountIfNeeded(account) + .then((refreshedAccount: AccountInfo) => { + return this.mastodonService.unmute(refreshedAccount, accounId); + }); + } + block(account: AccountInfo, accounId: number): Promise { return this.refreshAccountIfNeeded(account) .then((refreshedAccount: AccountInfo) => { @@ -386,6 +393,13 @@ export class MastodonWrapperService { }); } + unblock(account: AccountInfo, accounId: number): Promise { + return this.refreshAccountIfNeeded(account) + .then((refreshedAccount: AccountInfo) => { + return this.mastodonService.unblock(refreshedAccount, accounId); + }); + } + pinOnProfile(account: AccountInfo, statusId: string): Promise { return this.refreshAccountIfNeeded(account) .then((refreshedAccount: AccountInfo) => { diff --git a/src/app/services/mastodon.service.ts b/src/app/services/mastodon.service.ts index 1cef131c..4b985817 100644 --- a/src/app/services/mastodon.service.ts +++ b/src/app/services/mastodon.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpHeaders, HttpClient, HttpResponse } from '@angular/common/http'; import { ApiRoutes } from './models/api.settings'; -import { Account, Status, Results, Context, Relationship, Instance, Attachment, Notification, List, Poll, Emoji, Conversation, ScheduledStatus, Tag } from "./models/mastodon.interfaces"; +import { Account, Status, Results, Context, Relationship, Instance, Attachment, Notification, List, Poll, Emoji, Conversation, ScheduledStatus, Tag, Instancev2, Instancev1 } from "./models/mastodon.interfaces"; import { AccountInfo } from '../states/accounts.state'; import { StreamTypeEnum, StreamElement } from '../states/streams.state'; @@ -13,8 +13,12 @@ export class MastodonService { constructor(private readonly httpClient: HttpClient) { } getInstance(instance: string): Promise { - const route = `https://${instance}${this.apiRoutes.getInstance}`; - return this.httpClient.get(route).toPromise(); + let route = `https://${instance}${this.apiRoutes.getInstancev2}`; + return this.httpClient.get(route).toPromise() + .catch(err => { + route = `https://${instance}${this.apiRoutes.getInstance}`; + return this.httpClient.get(route).toPromise(); + }); } retrieveAccountDetails(account: AccountInfo): Promise { @@ -128,12 +132,13 @@ export class MastodonService { return this.httpClient.post(url, statusData, { headers: headers }).toPromise(); } - editStatus(account: AccountInfo, statusId: string, status: string, visibility: VisibilityEnum, spoiler: string = null, in_reply_to_id: string = null, mediaIds: string[], poll: PollParameters = null, scheduled_at: string = null): Promise { + editStatus(account: AccountInfo, statusId: string, status: string, visibility: VisibilityEnum, spoiler: string = null, in_reply_to_id: string = null, attachements: Attachment[], poll: PollParameters = null, scheduled_at: string = null): Promise { const url = `https://${account.instance}${this.apiRoutes.editStatus.replace('{0}', statusId)}`; const statusData = new StatusData(); statusData.status = status; - statusData.media_ids = mediaIds; + statusData.media_ids = attachements.map(x => x.id); + statusData.media_attributes = attachements.map(x => new MediaAttributes(x.id, x.description)); if (poll) { statusData['poll'] = poll; @@ -373,7 +378,7 @@ export class MastodonService { return this.httpClient.put(route, input, { headers: headers }).toPromise(); } - getNotifications(account: AccountInfo, excludeTypes: ('follow' | 'favourite' | 'reblog' | 'mention' | 'poll' | 'follow_request' | 'move')[] = null, maxId: string = null, sinceId: string = null, limit: number = 15): Promise { + getNotifications(account: AccountInfo, excludeTypes: ('follow' | 'favourite' | 'reblog' | 'mention' | 'poll' | 'follow_request' | 'move' | 'update')[] = null, maxId: string = null, sinceId: string = null, limit: number = 15): Promise { let route = `https://${account.instance}${this.apiRoutes.getNotifications}?limit=${limit}`; if (maxId) { @@ -482,12 +487,24 @@ export class MastodonService { return this.httpClient.post(route, null, { headers: headers }).toPromise(); } + unmute(account: AccountInfo, accounId: number): Promise { + let route = `https://${account.instance}${this.apiRoutes.unmute}`.replace('{0}', accounId.toString()); + const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` }); + return this.httpClient.post(route, null, { headers: headers }).toPromise(); + } + block(account: AccountInfo, accounId: number): Promise { let route = `https://${account.instance}${this.apiRoutes.block}`.replace('{0}', accounId.toString()); const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` }); return this.httpClient.post(route, null, { headers: headers }).toPromise(); } + unblock(account: AccountInfo, accounId: number): Promise { + let route = `https://${account.instance}${this.apiRoutes.unblock}`.replace('{0}', accounId.toString()); + const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` }); + return this.httpClient.post(route, null, { headers: headers }).toPromise(); + } + pinOnProfile(account: AccountInfo, statusId: string): Promise { let route = `https://${account.instance}${this.apiRoutes.pinStatus}`.replace('{0}', statusId.toString()); const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` }); @@ -627,6 +644,8 @@ class StatusData { status: string; in_reply_to_id: string; media_ids: string[]; + media_attributes: MediaAttributes[]; + // poll: PollParameters; sensitive: boolean; spoiler_text: string; @@ -634,6 +653,13 @@ class StatusData { // scheduled_at: string; } +class MediaAttributes { + constructor( + public id: string, + public description: string){ + } +} + export class PollParameters { options: string[] = []; expires_in: number; diff --git a/src/app/services/media.service.ts b/src/app/services/media.service.ts index 0fe3d9ab..b3c72470 100644 --- a/src/app/services/media.service.ts +++ b/src/app/services/media.service.ts @@ -51,24 +51,63 @@ export class MediaService { }); } - update(account: AccountInfo, media: MediaWrapper) { + loadMedia(attachments: Attachment[]) { + const wrappers: MediaWrapper[] = []; + + for (const att of attachments) { + const uniqueId = `${att.id}${Math.random()}`; + const wrapper = new MediaWrapper(uniqueId, null, att); + wrapper.description = att.description; + wrapper.isEdited = true; + wrappers.push(wrapper); + } + + this.mediaSubject.next(wrappers); + + } + + update(account: AccountInfo, media: MediaWrapper): Promise { if (media.attachment.description === media.description) return; - this.mastodonService.updateMediaAttachment(account, media.attachment.id, media.description) - .then((att: Attachment) => { - let medias = this.mediaSubject.value; - let updatedMedia = medias.filter(x => x.id === media.id)[0]; - updatedMedia.attachment.description = att.description; - this.mediaSubject.next(medias); - }) - .catch((err) => { - this.notificationService.notifyHttpError(err, account); - }); + if (media.isEdited) { + media.attachment.description = media.description; + + let medias = this.mediaSubject.value; + let updatedMedia = medias.filter(x => x.id === media.id)[0]; + updatedMedia.attachment.description = media.attachment.description; + this.mediaSubject.next(medias); + } else { + return this.mastodonService.updateMediaAttachment(account, media.attachment.id, media.description) + .then((att: Attachment) => { + let medias = this.mediaSubject.value; + let updatedMedia = medias.filter(x => x.id === media.id)[0]; + updatedMedia.attachment.description = att.description; + this.mediaSubject.next(medias); + }) + .catch((err) => { + console.warn('failing update'); + this.notificationService.notifyHttpError(err, account); + }); + } } - addExistingMedia(media: MediaWrapper){ - if(!this.fileCache[media.attachment.url]) return; - + async retrieveUpToDateMedia(account: AccountInfo): Promise { + const allMedia = this.mediaSubject.value; + let allPromises: Promise[] = []; + + for (const m of allMedia) { + let t = this.update(account, m); + allPromises.push(t); + } + + await Promise.all(allPromises); + + return allMedia; + } + + addExistingMedia(media: MediaWrapper) { + if (!this.fileCache[media.attachment.url]) return; + media.file = this.fileCache[media.attachment.url]; let medias = this.mediaSubject.value; medias.push(media); @@ -88,11 +127,15 @@ export class MediaService { migrateMedias(account: AccountInfo) { let medias = this.mediaSubject.value; medias.forEach(media => { - media.isMigrating = true; + if (!media.isEdited) { + media.isMigrating = true; + } }); this.mediaSubject.next(medias); for (let media of medias) { + if (media.isEdited) continue; + this.mastodonService.uploadMediaAttachment(account, media.file, media.description) .then((attachment: Attachment) => { this.fileCache[attachment.url] = media.file; @@ -117,7 +160,7 @@ export class MediaWrapper { public id: string, public file: File, attachment: Attachment) { - this.attachment = attachment; + this.attachment = attachment; } private _attachment: Attachment; @@ -125,7 +168,7 @@ export class MediaWrapper { return this._attachment; } - public set attachment(value: Attachment){ + public set attachment(value: Attachment) { if (value && value.meta && value.meta.audio_encode) { this.audioType = `audio/${value.meta.audio_encode}`; } else if (value && value.pleroma && value.pleroma.mime_type) { @@ -138,4 +181,6 @@ export class MediaWrapper { public description: string; public isMigrating: boolean; public audioType: string; + + public isEdited: boolean; } diff --git a/src/app/services/models/api.settings.ts b/src/app/services/models/api.settings.ts index f0bb1c10..50e8d614 100644 --- a/src/app/services/models/api.settings.ts +++ b/src/app/services/models/api.settings.ts @@ -25,6 +25,7 @@ export class ApiRoutes { rejectFollowRequest = '/api/v1/follow_requests/{0}/reject'; followRemote = '/api/v1/follows'; getInstance = '/api/v1/instance'; + getInstancev2 = '/api/v2/instance'; uploadMediaAttachment = '/api/v1/media'; updateMediaAttachment = '/api/v1/media/{0}'; getMutes = '/api/v1/mutes'; diff --git a/src/app/services/models/mastodon.interfaces.ts b/src/app/services/models/mastodon.interfaces.ts index 8ba398a5..ec31ddbf 100644 --- a/src/app/services/models/mastodon.interfaces.ts +++ b/src/app/services/models/mastodon.interfaces.ts @@ -110,17 +110,43 @@ export interface Error { error: string; } + + export interface Instance { - uri: string; title: string; - description: string; - email: string; version: string; - urls: string[]; + description: string; +} + +export interface Instancev1 extends Instance { + uri: string; + email: string; + urls: InstanceUrls; contact_account: Account; max_toot_chars: number; } +export interface Instancev2 extends Instance { + configuration: Instancev2Configuration +} + +export interface Instancev2Configuration { + urls: Instancev2Urls; + statuses: Instancev2Statuses +} + +export interface InstanceUrls { + streaming_api: string; +} + +export interface Instancev2Urls { + streaming: string; +} + +export interface Instancev2Statuses { + max_characters: number; +} + export interface Mention { url: string; username: string; @@ -130,7 +156,7 @@ export interface Mention { export interface Notification { id: string; - type: 'mention' | 'reblog' | 'favourite' | 'follow' | 'poll' | 'follow_request' | 'move'; + type: 'mention' | 'reblog' | 'favourite' | 'follow' | 'poll' | 'follow_request' | 'move' | 'update'; created_at: string; account: Account; status?: Status; diff --git a/src/app/services/notification.service.ts b/src/app/services/notification.service.ts index 7ed28448..a1a319c9 100644 --- a/src/app/services/notification.service.ts +++ b/src/app/services/notification.service.ts @@ -26,15 +26,15 @@ export class NotificationService { public notifyHttpError(err: HttpErrorResponse, account: AccountInfo) { let message = 'Oops, Unknown Error'; let code: number; - + try { code = err.status; - if(err.message){ - message = err.message; - } else if(err.error && err.error.error) { + if(err.error && err.error.error) { message = err.error.error; //Mastodon } else if(err.error && err.error.errors && err.error.errors.detail){ message = err.error.errors.detail; //Pleroma + } else if(err.message){ + message = err.message; } } catch (err) { } diff --git a/src/app/services/streaming.service.ts b/src/app/services/streaming.service.ts index 7076bb1c..92f91ff3 100644 --- a/src/app/services/streaming.service.ts +++ b/src/app/services/streaming.service.ts @@ -6,6 +6,7 @@ import { ApiRoutes } from "./models/api.settings"; import { StreamTypeEnum, StreamElement } from "../states/streams.state"; import { MastodonWrapperService } from "./mastodon-wrapper.service"; import { AccountInfo } from "../states/accounts.state"; +import { InstanceInfo, ToolsService } from "./tools.service"; @Injectable() export class StreamingService { @@ -13,12 +14,13 @@ export class StreamingService { public readonly nbStatusPerIteration: number = 20; constructor( - private readonly mastodonService: MastodonWrapperService) { } + private readonly mastodonService: MastodonWrapperService, + private readonly toolsService: ToolsService) { } getStreaming(accountInfo: AccountInfo, stream: StreamElement, since_id: string = null): StreamingWrapper { //new EventSourceStreaminWrapper(accountInfo, stream); - return new StreamingWrapper(this.mastodonService, accountInfo, stream, this.nbStatusPerIteration); + return new StreamingWrapper(this.mastodonService, this.toolsService, accountInfo, stream, this.nbStatusPerIteration); } } @@ -33,6 +35,7 @@ export class StreamingWrapper { constructor( private readonly mastodonService: MastodonWrapperService, + private readonly toolsService: ToolsService, private readonly account: AccountInfo, private readonly stream: StreamElement, private readonly nbStatusPerIteration: number, @@ -53,7 +56,13 @@ export class StreamingWrapper { return account; }) .then((refreshedAccount: AccountInfo) => { - const route = this.getRoute(refreshedAccount, stream); + let getInstanceProms = this.toolsService.getInstanceInfo(refreshedAccount); + return getInstanceProms.then(inst => { + return new StreamingAccountInfo(inst, refreshedAccount); + }); + }) + .then((account: StreamingAccountInfo) => { + const route = this.getRoute(account.instanceInfo, account.refreshedAccount, stream); this.eventSource = new WebSocket(route); this.eventSource.onmessage = x => { if (x.data !== '') { @@ -62,7 +71,7 @@ export class StreamingWrapper { } this.eventSource.onerror = x => this.webSocketGotError(x); this.eventSource.onopen = x => { }; - this.eventSource.onclose = x => this.webSocketClosed(refreshedAccount, stream, x); + this.eventSource.onclose = x => this.webSocketClosed(account.refreshedAccount, stream, x); }); } @@ -87,7 +96,7 @@ export class StreamingWrapper { } private pullNewNotifications() { - this.mastodonService.getNotifications(this.account, null, null, this.since_id_notifications, 10) + this.mastodonService.getNotifications(this.account, ['update'], null, this.since_id_notifications, 10) .then((notifications: Notification[]) => { //notifications = notifications.sort((a, b) => a.id.localeCompare(b.id)); let soundMuted = !this.since_id_notifications; @@ -159,12 +168,22 @@ export class StreamingWrapper { newUpdate.type = EventEnum.unknow; } + if(newUpdate.notification && newUpdate.notification.type === 'update') { //FIXME: disabling edition update until supported + return; + } + this.statusUpdateSubjet.next(newUpdate); } - private getRoute(account: AccountInfo, stream: StreamElement): string { + private getRoute(instanceInfo: InstanceInfo, account: AccountInfo, stream: StreamElement): string { + let streamingEndpoint = `wss://${account.instance}`; + + if(instanceInfo.major >= 4){ + streamingEndpoint = instanceInfo.streamingApi; + } + const streamingRouteType = this.getStreamingRouteType(stream.type); - let route = `wss://${account.instance}${this.apiRoutes.getStreaming}`.replace('{0}', account.token.access_token).replace('{1}', streamingRouteType); + let route = `${streamingEndpoint}${this.apiRoutes.getStreaming}`.replace('{0}', account.token.access_token).replace('{1}', streamingRouteType); if (stream.tag) route = `${route}&tag=${stream.tag}`; if (stream.list) route = `${route}&list=${stream.listId}`; @@ -274,6 +293,13 @@ class WebSocketEvent { payload: any; } +class StreamingAccountInfo { + constructor( + public instanceInfo: InstanceInfo, + public refreshedAccount: AccountInfo) { + } +} + export class StatusUpdate { type: EventEnum; status: Status; diff --git a/src/app/services/tools.service.ts b/src/app/services/tools.service.ts index a6eaa891..ea1da0d6 100644 --- a/src/app/services/tools.service.ts +++ b/src/app/services/tools.service.ts @@ -3,7 +3,7 @@ import { Store } from '@ngxs/store'; import { AccountInfo } from '../states/accounts.state'; import { MastodonWrapperService } from './mastodon-wrapper.service'; -import { Account, Results, Status, Emoji } from "./models/mastodon.interfaces"; +import { Account, Results, Status, Emoji, Instancev2, Instancev1 } from "./models/mastodon.interfaces"; import { StatusWrapper } from '../models/common.model'; import { AccountSettings, SaveAccountSettings, GlobalSettings, SaveSettings, ContentWarningPolicy, SaveContentWarningPolicy, ContentWarningPolicyEnum, TimeLineModeEnum, TimeLineHeaderEnum } from '../states/settings.state'; import { SettingsService } from './settings.service'; @@ -78,7 +78,7 @@ export class ToolsService { } else { return this.mastodonService.getInstance(acc.instance) .then(instance => { - var type = InstanceType.Mastodon; + let type = InstanceType.Mastodon; if (instance.version.toLowerCase().includes('pleroma')) { type = InstanceType.Pleroma; } else if (instance.version.toLowerCase().includes('+glitch')) { @@ -89,11 +89,26 @@ export class ToolsService { type = InstanceType.Pixelfed; } - var splittedVersion = instance.version.split('.'); - var major = +splittedVersion[0]; - var minor = +splittedVersion[1]; + const splittedVersion = instance.version.split('.'); + const major = +splittedVersion[0]; + const minor = +splittedVersion[1]; - var instanceInfo = new InstanceInfo(type, major, minor); + let streamingApi = ""; + + if (major >= 4) { + const instanceV2 = instance; + + if (instanceV2 + && instanceV2.configuration + && instanceV2.configuration.urls) + streamingApi = instanceV2.configuration.urls.streaming; + } else { + const instanceV1 = instance; + if (instanceV1 && instanceV1.urls) + streamingApi = instanceV1.urls.streaming_api; + } + + let instanceInfo = new InstanceInfo(type, major, minor, streamingApi); this.instanceInfos[acc.instance] = instanceInfo; return instanceInfo; @@ -231,7 +246,8 @@ export class InstanceInfo { constructor( public readonly type: InstanceType, public readonly major: number, - public readonly minor: number) { + public readonly minor: number, + public readonly streamingApi: string) { } } diff --git a/src/app/services/user-notification.service.ts b/src/app/services/user-notification.service.ts index 1c5b3a9a..370d4dc1 100644 --- a/src/app/services/user-notification.service.ts +++ b/src/app/services/user-notification.service.ts @@ -58,7 +58,7 @@ export class UserNotificationService { } private startFetchingNotifications(account: AccountInfo) { - let getMentionsPromise = this.mastodonService.getNotifications(account, ['favourite', 'follow', 'reblog', 'poll', 'follow_request', 'move'], null, null, 10) + let getMentionsPromise = this.mastodonService.getNotifications(account, ['favourite', 'follow', 'reblog', 'poll', 'follow_request', 'move', 'update'], null, null, 10) .then((notifications: Notification[]) => { this.processMentionsAndNotifications(account, notifications, NotificationTypeEnum.UserMention); }) @@ -66,7 +66,7 @@ export class UserNotificationService { this.notificationService.notifyHttpError(err, account); }); - let getNotificationPromise = this.mastodonService.getNotifications(account, ['mention'], null, null, 10) + let getNotificationPromise = this.mastodonService.getNotifications(account, ['mention', 'update'], null, null, 10) .then((notifications: Notification[]) => { this.processMentionsAndNotifications(account, notifications, NotificationTypeEnum.UserNotification); })