Add user search page
[bloat] / service / service.go
1 package service
2
3 import (
4         "context"
5         "errors"
6         "fmt"
7         "mime/multipart"
8         "net/url"
9         "strings"
10
11         "bloat/model"
12         "bloat/renderer"
13         "bloat/util"
14         "mastodon"
15 )
16
17 var (
18         errInvalidArgument = errors.New("invalid argument")
19 )
20
21 type Service interface {
22         ServeErrorPage(ctx context.Context, c *model.Client, err error)
23         ServeSigninPage(ctx context.Context, c *model.Client) (err error)
24         ServeTimelinePage(ctx context.Context, c *model.Client, tType string, maxID string, minID string) (err error)
25         ServeThreadPage(ctx context.Context, c *model.Client, id string, reply bool) (err error)
26         ServeLikedByPage(ctx context.Context, c *model.Client, id string) (err error)
27         ServeRetweetedByPage(ctx context.Context, c *model.Client, id string) (err error)
28         ServeFollowingPage(ctx context.Context, c *model.Client, id string, maxID string, minID string) (err error)
29         ServeFollowersPage(ctx context.Context, c *model.Client, id string, maxID string, minID string) (err error)
30         ServeNotificationPage(ctx context.Context, c *model.Client, maxID string, minID string) (err error)
31         ServeUserPage(ctx context.Context, c *model.Client, id string, maxID string, minID string) (err error)
32         ServeAboutPage(ctx context.Context, c *model.Client) (err error)
33         ServeEmojiPage(ctx context.Context, c *model.Client) (err error)
34         ServeSearchPage(ctx context.Context, c *model.Client, q string, qType string, offset int) (err error)
35         ServeUserSearchPage(ctx context.Context, c *model.Client, id string, q string, offset int) (err error)
36         ServeSettingsPage(ctx context.Context, c *model.Client) (err error)
37         NewSession(ctx context.Context, instance string) (redirectUrl string, sessionID string, err error)
38         Signin(ctx context.Context, c *model.Client, sessionID string, code string) (token string, err error)
39         Post(ctx context.Context, c *model.Client, content string, replyToID string, format string,
40                 visibility string, isNSFW bool, files []*multipart.FileHeader) (id string, err error)
41         Like(ctx context.Context, c *model.Client, id string) (count int64, err error)
42         UnLike(ctx context.Context, c *model.Client, id string) (count int64, err error)
43         Retweet(ctx context.Context, c *model.Client, id string) (count int64, err error)
44         UnRetweet(ctx context.Context, c *model.Client, id string) (count int64, err error)
45         Follow(ctx context.Context, c *model.Client, id string) (err error)
46         UnFollow(ctx context.Context, c *model.Client, id string) (err error)
47         SaveSettings(ctx context.Context, c *model.Client, settings *model.Settings) (err error)
48 }
49
50 type service struct {
51         clientName    string
52         clientScope   string
53         clientWebsite string
54         customCSS     string
55         postFormats   []model.PostFormat
56         renderer      renderer.Renderer
57         sessionRepo   model.SessionRepo
58         appRepo       model.AppRepo
59 }
60
61 func NewService(clientName string,
62         clientScope string,
63         clientWebsite string,
64         customCSS string,
65         postFormats []model.PostFormat,
66         renderer renderer.Renderer,
67         sessionRepo model.SessionRepo,
68         appRepo model.AppRepo,
69 ) Service {
70         return &service{
71                 clientName:    clientName,
72                 clientScope:   clientScope,
73                 clientWebsite: clientWebsite,
74                 customCSS:     customCSS,
75                 postFormats:   postFormats,
76                 renderer:      renderer,
77                 sessionRepo:   sessionRepo,
78                 appRepo:       appRepo,
79         }
80 }
81
82 func getRendererContext(c *model.Client) *renderer.Context {
83         var settings model.Settings
84         var session model.Session
85         if c != nil {
86                 settings = c.Session.Settings
87                 session = c.Session
88         } else {
89                 settings = *model.NewSettings()
90         }
91         return &renderer.Context{
92                 MaskNSFW:       settings.MaskNSFW,
93                 ThreadInNewTab: settings.ThreadInNewTab,
94                 FluorideMode:   settings.FluorideMode,
95                 DarkMode:       settings.DarkMode,
96                 CSRFToken:      session.CSRFToken,
97         }
98 }
99
100 func addToReplyMap(m map[string][]mastodon.ReplyInfo, key interface{},
101         val string, number int) {
102         if key == nil {
103                 return
104         }
105
106         keyStr, ok := key.(string)
107         if !ok {
108                 return
109         }
110
111         _, ok = m[keyStr]
112         if !ok {
113                 m[keyStr] = []mastodon.ReplyInfo{}
114         }
115
116         m[keyStr] = append(m[keyStr], mastodon.ReplyInfo{val, number})
117 }
118
119 func (svc *service) getCommonData(ctx context.Context, c *model.Client,
120         title string) (data *renderer.CommonData, err error) {
121
122         data = new(renderer.CommonData)
123         data.HeaderData = &renderer.HeaderData{
124                 Title:             title + " - " + svc.clientName,
125                 NotificationCount: 0,
126                 CustomCSS:         svc.customCSS,
127         }
128
129         if c == nil || !c.Session.IsLoggedIn() {
130                 return
131         }
132
133         notifications, err := c.GetNotifications(ctx, nil)
134         if err != nil {
135                 return nil, err
136         }
137
138         var notificationCount int
139         for i := range notifications {
140                 if notifications[i].Pleroma != nil &&
141                         !notifications[i].Pleroma.IsSeen {
142                         notificationCount++
143                 }
144         }
145
146         u, err := c.GetAccountCurrentUser(ctx)
147         if err != nil {
148                 return nil, err
149         }
150
151         data.NavbarData = &renderer.NavbarData{
152                 User:              u,
153                 NotificationCount: notificationCount,
154         }
155
156         data.HeaderData.NotificationCount = notificationCount
157         data.HeaderData.CSRFToken = c.Session.CSRFToken
158
159         return
160 }
161
162 func (svc *service) ServeErrorPage(ctx context.Context, c *model.Client, err error) {
163         var errStr string
164         if err != nil {
165                 errStr = err.Error()
166         }
167
168         commonData, err := svc.getCommonData(ctx, nil, "error")
169         if err != nil {
170                 return
171         }
172
173         data := &renderer.ErrorData{
174                 CommonData: commonData,
175                 Error:      errStr,
176         }
177
178         rCtx := getRendererContext(c)
179         svc.renderer.RenderErrorPage(rCtx, c.Writer, data)
180 }
181
182 func (svc *service) ServeSigninPage(ctx context.Context, c *model.Client) (
183         err error) {
184
185         commonData, err := svc.getCommonData(ctx, nil, "signin")
186         if err != nil {
187                 return
188         }
189
190         data := &renderer.SigninData{
191                 CommonData: commonData,
192         }
193
194         rCtx := getRendererContext(nil)
195         return svc.renderer.RenderSigninPage(rCtx, c.Writer, data)
196 }
197
198 func (svc *service) ServeTimelinePage(ctx context.Context, c *model.Client,
199         tType string, maxID string, minID string) (err error) {
200
201         var nextLink, prevLink, title string
202         var statuses []*mastodon.Status
203         var pg = mastodon.Pagination{
204                 MaxID: maxID,
205                 MinID: minID,
206                 Limit: 20,
207         }
208
209         switch tType {
210         default:
211                 return errInvalidArgument
212         case "home":
213                 statuses, err = c.GetTimelineHome(ctx, &pg)
214                 title = "Timeline"
215         case "direct":
216                 statuses, err = c.GetTimelineDirect(ctx, &pg)
217                 title = "Local Timeline"
218         case "local":
219                 statuses, err = c.GetTimelinePublic(ctx, true, &pg)
220                 title = "Local Timeline"
221         case "twkn":
222                 statuses, err = c.GetTimelinePublic(ctx, false, &pg)
223                 title = "The Whole Known Network"
224         }
225         if err != nil {
226                 return err
227         }
228
229         for i := range statuses {
230                 if statuses[i].Reblog != nil {
231                         statuses[i].Reblog.RetweetedByID = statuses[i].ID
232                 }
233         }
234
235         if len(maxID) > 0 && len(statuses) > 0 {
236                 prevLink = fmt.Sprintf("/timeline/%s?min_id=%s", tType,
237                         statuses[0].ID)
238         }
239
240         if len(minID) > 0 && len(pg.MinID) > 0 {
241                 newPg := &mastodon.Pagination{MinID: pg.MinID, Limit: 20}
242                 newStatuses, err := c.GetTimelineHome(ctx, newPg)
243                 if err != nil {
244                         return err
245                 }
246                 newLen := len(newStatuses)
247                 if newLen == 20 {
248                         prevLink = fmt.Sprintf("/timeline/%s?min_id=%s",
249                                 tType, pg.MinID)
250                 } else {
251                         i := 20 - newLen - 1
252                         if len(statuses) > i {
253                                 prevLink = fmt.Sprintf("/timeline/%s?min_id=%s",
254                                         tType, statuses[i].ID)
255                         }
256                 }
257         }
258
259         if len(pg.MaxID) > 0 {
260                 nextLink = fmt.Sprintf("/timeline/%s?max_id=%s", tType, pg.MaxID)
261         }
262
263         postContext := model.PostContext{
264                 DefaultVisibility: c.Session.Settings.DefaultVisibility,
265                 Formats:           svc.postFormats,
266         }
267
268         commonData, err := svc.getCommonData(ctx, c, tType+" timeline ")
269         if err != nil {
270                 return
271         }
272
273         data := &renderer.TimelineData{
274                 Title:       title,
275                 Statuses:    statuses,
276                 NextLink:    nextLink,
277                 PrevLink:    prevLink,
278                 PostContext: postContext,
279                 CommonData:  commonData,
280         }
281
282         rCtx := getRendererContext(c)
283         return svc.renderer.RenderTimelinePage(rCtx, c.Writer, data)
284 }
285
286 func (svc *service) ServeThreadPage(ctx context.Context, c *model.Client,
287         id string, reply bool) (err error) {
288
289         var postContext model.PostContext
290
291         status, err := c.GetStatus(ctx, id)
292         if err != nil {
293                 return
294         }
295
296         u, err := c.GetAccountCurrentUser(ctx)
297         if err != nil {
298                 return
299         }
300
301         if reply {
302                 var content string
303                 var visibility string
304                 if u.ID != status.Account.ID {
305                         content += "@" + status.Account.Acct + " "
306                 }
307                 for i := range status.Mentions {
308                         if status.Mentions[i].ID != u.ID &&
309                                 status.Mentions[i].ID != status.Account.ID {
310                                 content += "@" + status.Mentions[i].Acct + " "
311                         }
312                 }
313
314                 if c.Session.Settings.CopyScope {
315                         s, err := c.GetStatus(ctx, id)
316                         if err != nil {
317                                 return err
318                         }
319                         visibility = s.Visibility
320                 } else {
321                         visibility = c.Session.Settings.DefaultVisibility
322                 }
323
324                 postContext = model.PostContext{
325                         DefaultVisibility: visibility,
326                         Formats:           svc.postFormats,
327                         ReplyContext: &model.ReplyContext{
328                                 InReplyToID:   id,
329                                 InReplyToName: status.Account.Acct,
330                                 ReplyContent:  content,
331                         },
332                         DarkMode: c.Session.Settings.DarkMode,
333                 }
334         }
335
336         context, err := c.GetStatusContext(ctx, id)
337         if err != nil {
338                 return
339         }
340
341         statuses := append(append(context.Ancestors, status), context.Descendants...)
342         replies := make(map[string][]mastodon.ReplyInfo)
343
344         for i := range statuses {
345                 statuses[i].ShowReplies = true
346                 statuses[i].ReplyMap = replies
347                 addToReplyMap(replies, statuses[i].InReplyToID, statuses[i].ID, i+1)
348         }
349
350         commonData, err := svc.getCommonData(ctx, c, "post by "+status.Account.DisplayName)
351         if err != nil {
352                 return
353         }
354
355         data := &renderer.ThreadData{
356                 Statuses:    statuses,
357                 PostContext: postContext,
358                 ReplyMap:    replies,
359                 CommonData:  commonData,
360         }
361
362         rCtx := getRendererContext(c)
363         return svc.renderer.RenderThreadPage(rCtx, c.Writer, data)
364 }
365
366 func (svc *service) ServeLikedByPage(ctx context.Context, c *model.Client,
367         id string) (err error) {
368
369         likers, err := c.GetFavouritedBy(ctx, id, nil)
370         if err != nil {
371                 return
372         }
373
374         commonData, err := svc.getCommonData(ctx, c, "likes")
375         if err != nil {
376                 return
377         }
378
379         data := &renderer.LikedByData{
380                 CommonData: commonData,
381                 Users:      likers,
382         }
383
384         rCtx := getRendererContext(c)
385         return svc.renderer.RenderLikedByPage(rCtx, c.Writer, data)
386 }
387
388 func (svc *service) ServeRetweetedByPage(ctx context.Context, c *model.Client,
389         id string) (err error) {
390
391         retweeters, err := c.GetRebloggedBy(ctx, id, nil)
392         if err != nil {
393                 return
394         }
395
396         commonData, err := svc.getCommonData(ctx, c, "retweets")
397         if err != nil {
398                 return
399         }
400
401         data := &renderer.RetweetedByData{
402                 CommonData: commonData,
403                 Users:      retweeters,
404         }
405
406         rCtx := getRendererContext(c)
407         return svc.renderer.RenderRetweetedByPage(rCtx, c.Writer, data)
408 }
409
410 func (svc *service) ServeFollowingPage(ctx context.Context, c *model.Client,
411         id string, maxID string, minID string) (err error) {
412
413         var nextLink string
414         var pg = mastodon.Pagination{
415                 MaxID: maxID,
416                 MinID: minID,
417                 Limit: 20,
418         }
419
420         followings, err := c.GetAccountFollowing(ctx, id, &pg)
421         if err != nil {
422                 return
423         }
424
425         if len(followings) == 20 && len(pg.MaxID) > 0 {
426                 nextLink = "/following/" + id + "?max_id=" + pg.MaxID
427         }
428
429         commonData, err := svc.getCommonData(ctx, c, "following")
430         if err != nil {
431                 return
432         }
433
434         data := &renderer.FollowingData{
435                 CommonData: commonData,
436                 Users:      followings,
437                 NextLink:   nextLink,
438         }
439
440         rCtx := getRendererContext(c)
441         return svc.renderer.RenderFollowingPage(rCtx, c.Writer, data)
442 }
443
444 func (svc *service) ServeFollowersPage(ctx context.Context, c *model.Client,
445         id string, maxID string, minID string) (err error) {
446
447         var nextLink string
448         var pg = mastodon.Pagination{
449                 MaxID: maxID,
450                 MinID: minID,
451                 Limit: 20,
452         }
453
454         followers, err := c.GetAccountFollowers(ctx, id, &pg)
455         if err != nil {
456                 return
457         }
458
459         if len(followers) == 20 && len(pg.MaxID) > 0 {
460                 nextLink = "/followers/" + id + "?max_id=" + pg.MaxID
461         }
462
463         commonData, err := svc.getCommonData(ctx, c, "followers")
464         if err != nil {
465                 return
466         }
467
468         data := &renderer.FollowersData{
469                 CommonData: commonData,
470                 Users:      followers,
471                 NextLink:   nextLink,
472         }
473         rCtx := getRendererContext(c)
474         return svc.renderer.RenderFollowersPage(rCtx, c.Writer, data)
475 }
476
477 func (svc *service) ServeNotificationPage(ctx context.Context, c *model.Client,
478         maxID string, minID string) (err error) {
479
480         var nextLink string
481         var unreadCount int
482         var pg = mastodon.Pagination{
483                 MaxID: maxID,
484                 MinID: minID,
485                 Limit: 20,
486         }
487
488         notifications, err := c.GetNotifications(ctx, &pg)
489         if err != nil {
490                 return
491         }
492
493         for i := range notifications {
494                 if notifications[i].Pleroma != nil && !notifications[i].Pleroma.IsSeen {
495                         unreadCount++
496                 }
497         }
498
499         if unreadCount > 0 {
500                 err := c.ReadNotifications(ctx, notifications[0].ID)
501                 if err != nil {
502                         return err
503                 }
504         }
505
506         if len(pg.MaxID) > 0 {
507                 nextLink = "/notifications?max_id=" + pg.MaxID
508         }
509
510         commonData, err := svc.getCommonData(ctx, c, "notifications")
511         if err != nil {
512                 return
513         }
514
515         data := &renderer.NotificationData{
516                 Notifications: notifications,
517                 NextLink:      nextLink,
518                 CommonData:    commonData,
519         }
520         rCtx := getRendererContext(c)
521         return svc.renderer.RenderNotificationPage(rCtx, c.Writer, data)
522 }
523
524 func (svc *service) ServeUserPage(ctx context.Context, c *model.Client,
525         id string, maxID string, minID string) (err error) {
526
527         var nextLink string
528
529         var pg = mastodon.Pagination{
530                 MaxID: maxID,
531                 MinID: minID,
532                 Limit: 20,
533         }
534
535         user, err := c.GetAccount(ctx, id)
536         if err != nil {
537                 return
538         }
539
540         statuses, err := c.GetAccountStatuses(ctx, id, &pg)
541         if err != nil {
542                 return
543         }
544
545         if len(pg.MaxID) > 0 {
546                 nextLink = "/user/" + id + "?max_id=" + pg.MaxID
547         }
548
549         commonData, err := svc.getCommonData(ctx, c, user.DisplayName)
550         if err != nil {
551                 return
552         }
553
554         data := &renderer.UserData{
555                 User:       user,
556                 Statuses:   statuses,
557                 NextLink:   nextLink,
558                 CommonData: commonData,
559         }
560         rCtx := getRendererContext(c)
561         return svc.renderer.RenderUserPage(rCtx, c.Writer, data)
562 }
563
564 func (svc *service) ServeUserSearchPage(ctx context.Context, c *model.Client,
565         id string, q string, offset int) (err error) {
566
567         var nextLink string
568         var title = "search"
569
570         user, err := c.GetAccount(ctx, id)
571         if err != nil {
572                 return
573         }
574
575         results, err := c.Search(ctx, q, "statuses", 20, true, offset, id)
576         if err != nil {
577                 return
578         }
579
580         if len(results.Statuses) == 20 {
581                 offset += 20
582                 nextLink = fmt.Sprintf("/usersearch/%s?q=%s&offset=%d", id, q, offset)
583         }
584
585         if len(q) > 0 {
586                 title += " \"" + q + "\""
587         }
588
589         commonData, err := svc.getCommonData(ctx, c, title)
590         if err != nil {
591                 return
592         }
593
594         data := &renderer.UserSearchData{
595                 CommonData: commonData,
596                 User:       user,
597                 Q:          q,
598                 Statuses:   results.Statuses,
599                 NextLink:   nextLink,
600         }
601
602         rCtx := getRendererContext(c)
603         return svc.renderer.RenderUserSearchPage(rCtx, c.Writer, data)
604 }
605
606 func (svc *service) ServeAboutPage(ctx context.Context, c *model.Client) (err error) {
607         commonData, err := svc.getCommonData(ctx, c, "about")
608         if err != nil {
609                 return
610         }
611
612         data := &renderer.AboutData{
613                 CommonData: commonData,
614         }
615
616         rCtx := getRendererContext(c)
617         return svc.renderer.RenderAboutPage(rCtx, c.Writer, data)
618 }
619
620 func (svc *service) ServeEmojiPage(ctx context.Context, c *model.Client) (err error) {
621         commonData, err := svc.getCommonData(ctx, c, "emojis")
622         if err != nil {
623                 return
624         }
625
626         emojis, err := c.GetInstanceEmojis(ctx)
627         if err != nil {
628                 return
629         }
630
631         data := &renderer.EmojiData{
632                 Emojis:     emojis,
633                 CommonData: commonData,
634         }
635
636         rCtx := getRendererContext(c)
637         return svc.renderer.RenderEmojiPage(rCtx, c.Writer, data)
638 }
639
640 func (svc *service) ServeSearchPage(ctx context.Context, c *model.Client,
641         q string, qType string, offset int) (err error) {
642
643         var nextLink string
644         var title = "search"
645
646         results, err := c.Search(ctx, q, qType, 20, true, offset, "")
647         if err != nil {
648                 return
649         }
650
651         if (qType == "accounts" && len(results.Accounts) == 20) ||
652                 (qType == "statuses" && len(results.Statuses) == 20) {
653                 offset += 20
654                 nextLink = fmt.Sprintf("/search?q=%s&type=%s&offset=%d", q, qType, offset)
655         }
656
657         if len(q) > 0 {
658                 title += " \"" + q + "\""
659         }
660
661         commonData, err := svc.getCommonData(ctx, c, title)
662         if err != nil {
663                 return
664         }
665
666         data := &renderer.SearchData{
667                 CommonData: commonData,
668                 Q:          q,
669                 Type:       qType,
670                 Users:      results.Accounts,
671                 Statuses:   results.Statuses,
672                 NextLink:   nextLink,
673         }
674
675         rCtx := getRendererContext(c)
676         return svc.renderer.RenderSearchPage(rCtx, c.Writer, data)
677 }
678
679 func (svc *service) ServeSettingsPage(ctx context.Context, c *model.Client) (err error) {
680         commonData, err := svc.getCommonData(ctx, c, "settings")
681         if err != nil {
682                 return
683         }
684
685         data := &renderer.SettingsData{
686                 CommonData: commonData,
687                 Settings:   &c.Session.Settings,
688         }
689
690         rCtx := getRendererContext(c)
691         return svc.renderer.RenderSettingsPage(rCtx, c.Writer, data)
692 }
693
694 func (svc *service) NewSession(ctx context.Context, instance string) (
695         redirectUrl string, sessionID string, err error) {
696
697         var instanceURL string
698         if strings.HasPrefix(instance, "https://") {
699                 instanceURL = instance
700                 instance = strings.TrimPrefix(instance, "https://")
701         } else {
702                 instanceURL = "https://" + instance
703         }
704
705         sessionID, err = util.NewSessionID()
706         if err != nil {
707                 return
708         }
709
710         csrfToken, err := util.NewCSRFToken()
711         if err != nil {
712                 return
713         }
714
715         session := model.Session{
716                 ID:             sessionID,
717                 InstanceDomain: instance,
718                 CSRFToken:      csrfToken,
719                 Settings:       *model.NewSettings(),
720         }
721
722         err = svc.sessionRepo.Add(session)
723         if err != nil {
724                 return
725         }
726
727         app, err := svc.appRepo.Get(instance)
728         if err != nil {
729                 if err != model.ErrAppNotFound {
730                         return
731                 }
732
733                 mastoApp, err := mastodon.RegisterApp(ctx, &mastodon.AppConfig{
734                         Server:       instanceURL,
735                         ClientName:   svc.clientName,
736                         Scopes:       svc.clientScope,
737                         Website:      svc.clientWebsite,
738                         RedirectURIs: svc.clientWebsite + "/oauth_callback",
739                 })
740                 if err != nil {
741                         return "", "", err
742                 }
743
744                 app = model.App{
745                         InstanceDomain: instance,
746                         InstanceURL:    instanceURL,
747                         ClientID:       mastoApp.ClientID,
748                         ClientSecret:   mastoApp.ClientSecret,
749                 }
750
751                 err = svc.appRepo.Add(app)
752                 if err != nil {
753                         return "", "", err
754                 }
755         }
756
757         u, err := url.Parse("/oauth/authorize")
758         if err != nil {
759                 return
760         }
761
762         q := make(url.Values)
763         q.Set("scope", "read write follow")
764         q.Set("client_id", app.ClientID)
765         q.Set("response_type", "code")
766         q.Set("redirect_uri", svc.clientWebsite+"/oauth_callback")
767         u.RawQuery = q.Encode()
768
769         redirectUrl = instanceURL + u.String()
770
771         return
772 }
773
774 func (svc *service) Signin(ctx context.Context, c *model.Client,
775         sessionID string, code string) (token string, err error) {
776
777         if len(code) < 1 {
778                 err = errInvalidArgument
779                 return
780         }
781
782         err = c.AuthenticateToken(ctx, code, svc.clientWebsite+"/oauth_callback")
783         if err != nil {
784                 return
785         }
786         token = c.GetAccessToken(ctx)
787
788         return
789 }
790
791 func (svc *service) Post(ctx context.Context, c *model.Client, content string,
792         replyToID string, format string, visibility string, isNSFW bool,
793         files []*multipart.FileHeader) (id string, err error) {
794
795         var mediaIDs []string
796         for _, f := range files {
797                 a, err := c.UploadMediaFromMultipartFileHeader(ctx, f)
798                 if err != nil {
799                         return "", err
800                 }
801                 mediaIDs = append(mediaIDs, a.ID)
802         }
803
804         tweet := &mastodon.Toot{
805                 Status:      content,
806                 InReplyToID: replyToID,
807                 MediaIDs:    mediaIDs,
808                 ContentType: format,
809                 Visibility:  visibility,
810                 Sensitive:   isNSFW,
811         }
812
813         s, err := c.PostStatus(ctx, tweet)
814         if err != nil {
815                 return
816         }
817
818         return s.ID, nil
819 }
820
821 func (svc *service) Like(ctx context.Context, c *model.Client, id string) (
822         count int64, err error) {
823         s, err := c.Favourite(ctx, id)
824         if err != nil {
825                 return
826         }
827         count = s.FavouritesCount
828         return
829 }
830
831 func (svc *service) UnLike(ctx context.Context, c *model.Client, id string) (
832         count int64, err error) {
833         s, err := c.Unfavourite(ctx, id)
834         if err != nil {
835                 return
836         }
837         count = s.FavouritesCount
838         return
839 }
840
841 func (svc *service) Retweet(ctx context.Context, c *model.Client, id string) (
842         count int64, err error) {
843         s, err := c.Reblog(ctx, id)
844         if err != nil {
845                 return
846         }
847         if s.Reblog != nil {
848                 count = s.Reblog.ReblogsCount
849         }
850         return
851 }
852
853 func (svc *service) UnRetweet(ctx context.Context, c *model.Client, id string) (
854         count int64, err error) {
855         s, err := c.Unreblog(ctx, id)
856         if err != nil {
857                 return
858         }
859         count = s.ReblogsCount
860         return
861 }
862
863 func (svc *service) Follow(ctx context.Context, c *model.Client, id string) (err error) {
864         _, err = c.AccountFollow(ctx, id)
865         return
866 }
867
868 func (svc *service) UnFollow(ctx context.Context, c *model.Client, id string) (err error) {
869         _, err = c.AccountUnfollow(ctx, id)
870         return
871 }
872
873 func (svc *service) SaveSettings(ctx context.Context, c *model.Client,
874         settings *model.Settings) (err error) {
875
876         session, err := svc.sessionRepo.Get(c.Session.ID)
877         if err != nil {
878                 return
879         }
880
881         session.Settings = *settings
882         return svc.sessionRepo.Add(session)
883 }